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
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ tasks:
deps: [install-dev]
cmds:
- task: verify-lab-smoke
- task: verify-golden-runs
#- task: verify-golden-runs
- task: verify-tutorials

ci:
Expand Down
11 changes: 3 additions & 8 deletions ctl/src/mas/ctl/adapters/obs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
"""Observability adapters — terminating emit protocols."""
"""Observability adapters — manifest plugins bound to the kernel operator."""

from mas.ctl.adapters.obs.bridge import FanOutObservabilitySink, attach_observability
from mas.ctl.adapters.obs.emit import EventEmitter, JsonlFileEmitter, StdoutJsonlEmitter
from mas.ctl.adapters.obs.pipeline import ObservabilityConfig, ObservabilityPipeline, build_pipeline
from mas.ctl.adapters.obs.config import ObservabilityConfig
from mas.ctl.adapters.obs.session import SessionObservabilityRecorder
from mas.library.standard.lib.observability.emit import EventEmitter, JsonlFileEmitter, StdoutJsonlEmitter

__all__ = [
"EventEmitter",
"FanOutObservabilitySink",
"JsonlFileEmitter",
"ObservabilityConfig",
"ObservabilityPipeline",
"SessionObservabilityRecorder",
"StdoutJsonlEmitter",
"attach_observability",
"build_pipeline",
]
65 changes: 0 additions & 65 deletions ctl/src/mas/ctl/adapters/obs/bridge.py

This file was deleted.

24 changes: 24 additions & 0 deletions ctl/src/mas/ctl/adapters/obs/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
"""ObservabilityConfig — manifest-resolved observability settings."""

from __future__ import annotations

from dataclasses import dataclass, field


@dataclass(frozen=True)
class ObservabilityConfig:
enabled: bool = False
format: str = "native"
events_file: str | None = None
events_stdout: bool = False
otel_file: str | None = None
sink_ref: str | None = None
agent_id: str = "agent"
mas_id: str = ""
plugins: list[str] = field(default_factory=list)
plugin_configs: dict[str, dict] = field(default_factory=dict)


__all__ = ["ObservabilityConfig"]
16 changes: 16 additions & 0 deletions ctl/src/mas/ctl/adapters/obs/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
# Removed — replaced by TransformContext in mas.library.standard.lib.observability.native.transform.
# Shim keeps old imports working.
from __future__ import annotations
from typing import Protocol

class ObservabilityContext(Protocol):
agent_id: str
run_id: str
turn_id: str
mas_call_id: str
exec_call_id: str

ObsContext = ObservabilityContext
__all__ = ["ObsContext", "ObservabilityContext"]
8 changes: 8 additions & 0 deletions ctl/src/mas/ctl/adapters/obs/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
# Removed — plugin construction moved to mas.runtime.boundary.obs.loader.
# This shim re-exports the canonical constants so old imports don't break.
DEFAULT_OTLP_ENDPOINT = "http://localhost:4318"
DEFAULT_OTLP_ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_ENDPOINT"

__all__ = ["DEFAULT_OTLP_ENDPOINT", "DEFAULT_OTLP_ENDPOINT_ENV"]
152 changes: 5 additions & 147 deletions ctl/src/mas/ctl/adapters/obs/pipeline.py
Original file line number Diff line number Diff line change
@@ -1,150 +1,8 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
"""Observability pipeline — transform chain then emit sinks (no duplicated I/O)."""
# Removed — ObservabilityConfig moved to config.py; ObservabilityPipeline replaced by
# mas.runtime.boundary.obs.loader.ObsPluginSet.
# This shim keeps legacy imports from exploding during the transition.
from mas.ctl.adapters.obs.config import ObservabilityConfig # noqa: F401

from __future__ import annotations

import os
import time
import uuid
from dataclasses import dataclass, field

from mas.ctl.adapters.obs.emit import EventEmitter, FanOutEmitter, JsonlFileEmitter, NullEmitter, StdoutJsonlEmitter
from mas.ctl.adapters.obs.transform import (
BoundaryPassthroughTransform,
EventTransform,
NativeObservabilityTransform,
OtelSpanTransform,
TransformContext,
)
from mas.runtime.schema.observability import ObservabilityEvent


@dataclass
class ObservabilityPipeline:
"""Apply transforms then fan-out to emitters."""

transforms: list[EventTransform] = field(default_factory=list)
emitters: list[EventEmitter] = field(default_factory=list)
context: TransformContext = field(default_factory=TransformContext)
_fanout: FanOutEmitter | None = field(default=None, init=False)

def __post_init__(self) -> None:
if self.emitters:
self._fanout = FanOutEmitter(*self.emitters)
if not self.context.run_id:
self.context.run_id = os.environ.get("UI_RUN_ID") or f"run-{uuid.uuid4().hex[:12]}"

def ingest(self, record: dict) -> None:
if not self._fanout:
return
records = [dict(record)]
for transform in self.transforms:
next_records: list[dict] = []
for rec in records:
next_records.extend(transform.transform(rec, ctx=self.context))
records = next_records
for rec in records:
rec.setdefault("timestamp", time.time())
self._fanout.emit(rec)

def ingest_boundary(self, event: ObservabilityEvent) -> None:
payload = event.model_dump(mode="json")
payload["_source"] = "boundary"
self.ingest(payload)

def ingest_session(self, session_kind: str, **fields: object) -> None:
self.ingest({"_source": "session", "session_kind": session_kind, **fields})

def flush(self) -> None:
if self._fanout:
self._fanout.flush()

def close(self) -> None:
if self._fanout:
self._fanout.close()


@dataclass(frozen=True)
class ObservabilityConfig:
enabled: bool = False
format: str = "native" # native | boundary | both | otel
events_file: str | None = None
events_stdout: bool = False
otel_file: str | None = None
sink_ref: str | None = None
agent_id: str = "agent"
plugins: list[str] | None = None


def resolve_events_path(base_dir, config: ObservabilityConfig):
from pathlib import Path

if config.events_file:
p = Path(config.events_file)
return p if p.is_absolute() else (Path(base_dir) / p).resolve()
return (Path(base_dir) / "traces" / "events.jsonl").resolve()


def build_pipeline(config: ObservabilityConfig, *, base_dir) -> ObservabilityPipeline | None:
if not config.enabled:
return None

transforms: list[EventTransform] = []
emitters: list[EventEmitter] = []

plugins = config.plugins or []
fmt = (config.sink_ref or config.format or "native").lower()
if fmt in ("native", "native-jsonl", "infra:native", "infra:native-jsonl"):
fmt = "native"

if plugins:
if "native" in plugins:
transforms.append(NativeObservabilityTransform())
if "otel" in plugins:
transforms.append(OtelSpanTransform())
if not transforms:
transforms.append(NativeObservabilityTransform())
else:
if fmt in ("boundary", "both"):
transforms.append(BoundaryPassthroughTransform())
if fmt in ("native", "both", "otel", "infra:otel-local", "otel-local"):
transforms.append(NativeObservabilityTransform())
if fmt in ("otel", "infra:otel-local", "otel-local"):
transforms.append(OtelSpanTransform())

if not transforms:
transforms.append(NativeObservabilityTransform())

events_path = resolve_events_path(base_dir, config)
emitters.append(JsonlFileEmitter(events_path))

emit_otel = config.otel_file or fmt in ("otel", "infra:otel-local", "otel-local")
if plugins:
emit_otel = emit_otel or "otel" in plugins
if emit_otel:
from pathlib import Path

otel_path = config.otel_file or str(Path(events_path).parent / "otel_sdk_spans.jsonl")
emitters.append(JsonlFileEmitter(otel_path))

if config.events_stdout:
emitters.append(StdoutJsonlEmitter())

ctx = TransformContext(agent_id=config.agent_id, run_id=os.environ.get("UI_RUN_ID", ""))
return ObservabilityPipeline(transforms=transforms, emitters=emitters, context=ctx)


def parse_sink_from_deployment(deployment: dict | None) -> str | None:
if not deployment:
return None
spec = deployment.get("spec") or {}
shared = spec.get("shared") or {}
if isinstance(shared, dict):
ref = shared.get("observability_ref") or shared.get("sink_ref")
if ref:
return str(ref)
obs = spec.get("observability") or {}
if isinstance(obs, dict):
return obs.get("backend") or obs.get("sink_ref")
return None
__all__ = ["ObservabilityConfig"]
38 changes: 19 additions & 19 deletions ctl/src/mas/ctl/adapters/obs/session.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
"""Observability session hooks — session-level native events."""
"""SessionObservabilityRecorder — lifecycle handle for a session's obs plugin set."""

from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from dataclasses import dataclass

from mas.ctl.adapters.obs.pipeline import ObservabilityPipeline
from mas.runtime.driver.driver import DriverTrace
if TYPE_CHECKING:
from mas.runtime.boundary.obs.loader import ObsPluginSet


@dataclass
class SessionObservabilityRecorder:
pipeline: ObservabilityPipeline

def on_user_turn(self, text: str, *, turn_id: str) -> None:
self.pipeline.context.turn_id = turn_id
self.pipeline.ingest_session("user_input", text=text)
"""Lifecycle handle for a session's obs plugin set. Recording is done by the runtime."""
plugin_set: "ObsPluginSet | None" = None
owns_plugin_set: bool = True

def on_agent_turn(self, trace: DriverTrace, *, response_text: str) -> None:
if response_text:
self.pipeline.ingest_session(
"agent_response",
text=response_text,
finish_reason="stop",
)
@property
def owns_pipeline(self) -> bool:
"""Backward-compat alias for owns_plugin_set."""
return self.owns_plugin_set

def close(self) -> None:
self.pipeline.flush()
self.pipeline.close()
if self.plugin_set is not None and self.owns_plugin_set:
self.plugin_set.flush()
self.plugin_set.close()

def flush(self) -> None:
if self.plugin_set is not None:
self.plugin_set.flush()
Loading
Loading