agharsallah Codex commited on
Commit Β·
cb2de15
1
Parent(s): a196e34
feat(observability): instrument registry, tools, session, modal (Units 7,8,10,11)
Browse files- registry: config.loaded, manifest.loaded, model-endpoint resolution (no api key),
cast assembly, governor configuration logs.
- tools: tool.call span + capability-check (granted/denied) logs + tool.calls metric.
- session: session.created/reset/step/inject logs + session.step span so a UI step
produces a full trace down through the conductor.
- modal: configure() + modal.run_episode span + episode start/done logs with token stats.
Co-Authored-By: Codex <codex@openai.com>
- modal_app.py +20 -6
- src/core/registry.py +32 -0
- src/tools/registry.py +20 -10
- src/ui/fishbowl/session.py +15 -2
modal_app.py
CHANGED
|
@@ -8,6 +8,7 @@ used only when you deploy it:
|
|
| 8 |
modal run modal_app.py # one-off episode
|
| 9 |
modal deploy modal_app.py # schedule it (hourly by default)
|
| 10 |
"""
|
|
|
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import modal
|
|
@@ -38,12 +39,14 @@ def run_episode(scenario_name: str = DEFAULT_SCENARIO, n_ticks: int = TICKS_PER_
|
|
| 38 |
import os
|
| 39 |
from pathlib import Path
|
| 40 |
|
|
|
|
| 41 |
from src.core.conductor import Conductor
|
| 42 |
from src.core.ledger_factory import database_url
|
| 43 |
from src.core.registry import default_registry
|
| 44 |
from src.core.sqlite_ledger import SQLiteLedger
|
| 45 |
from src.tools.builtins import default_tool_registry
|
| 46 |
|
|
|
|
| 47 |
db_path = f"/data/{scenario_name}.db"
|
| 48 |
reg = default_registry()
|
| 49 |
# Durable event store when DATABASE_URL is set (ADR-0014); otherwise the
|
|
@@ -62,12 +65,23 @@ def run_episode(scenario_name: str = DEFAULT_SCENARIO, n_ticks: int = TICKS_PER_
|
|
| 62 |
snapshot_path=f"/data/{scenario_name}.snapshot.db",
|
| 63 |
)
|
| 64 |
|
| 65 |
-
|
| 66 |
-
conductor.
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
@app.local_entrypoint()
|
|
|
|
| 8 |
modal run modal_app.py # one-off episode
|
| 9 |
modal deploy modal_app.py # schedule it (hourly by default)
|
| 10 |
"""
|
| 11 |
+
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import modal
|
|
|
|
| 39 |
import os
|
| 40 |
from pathlib import Path
|
| 41 |
|
| 42 |
+
from src import observability as obs
|
| 43 |
from src.core.conductor import Conductor
|
| 44 |
from src.core.ledger_factory import database_url
|
| 45 |
from src.core.registry import default_registry
|
| 46 |
from src.core.sqlite_ledger import SQLiteLedger
|
| 47 |
from src.tools.builtins import default_tool_registry
|
| 48 |
|
| 49 |
+
obs.configure()
|
| 50 |
db_path = f"/data/{scenario_name}.db"
|
| 51 |
reg = default_registry()
|
| 52 |
# Durable event store when DATABASE_URL is set (ADR-0014); otherwise the
|
|
|
|
| 65 |
snapshot_path=f"/data/{scenario_name}.snapshot.db",
|
| 66 |
)
|
| 67 |
|
| 68 |
+
with obs.span("modal.run_episode", **{"scenario": scenario_name, "modal.n_ticks": n_ticks}):
|
| 69 |
+
restored = conductor.restore()
|
| 70 |
+
if not restored:
|
| 71 |
+
conductor.reset(conductor.scenario.default_seed)
|
| 72 |
+
obs.log(
|
| 73 |
+
"modal.episode.start",
|
| 74 |
+
scenario=scenario_name,
|
| 75 |
+
n_ticks=n_ticks,
|
| 76 |
+
restored=restored,
|
| 77 |
+
durable=bool(database_url()),
|
| 78 |
+
)
|
| 79 |
+
conductor.step(n_ticks=n_ticks)
|
| 80 |
+
ledger.close()
|
| 81 |
+
volume.commit() # persist the ledger for the next scheduled run
|
| 82 |
+
stats = conductor.governor.stats
|
| 83 |
+
obs.log("modal.episode.done", scenario=scenario_name, turn=conductor.turn, stats=stats)
|
| 84 |
+
return {"scenario": scenario_name, "turn": conductor.turn, "stats": stats}
|
| 85 |
|
| 86 |
|
| 87 |
@app.local_entrypoint()
|
src/core/registry.py
CHANGED
|
@@ -21,6 +21,7 @@ from pathlib import Path
|
|
| 21 |
|
| 22 |
import yaml
|
| 23 |
|
|
|
|
| 24 |
from src.agents.base import Agent, ManifestAgent
|
| 25 |
from src.core.config import (
|
| 26 |
GovernorConfig,
|
|
@@ -103,6 +104,12 @@ def _resolve_model_endpoints(raw_models: dict, env: dict[str, str] | None = None
|
|
| 103 |
cfg.setdefault("model", binding["model"])
|
| 104 |
cfg.setdefault("base_url", binding["base_url"])
|
| 105 |
cfg.setdefault("api_key", binding["api_key"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
return raw_models
|
| 107 |
|
| 108 |
|
|
@@ -146,6 +153,14 @@ class Registry:
|
|
| 146 |
for path in sorted(agents_dir.glob("*.yaml")):
|
| 147 |
manifest = validate_agent(yaml.safe_load(path.read_text()) or {})
|
| 148 |
agents[manifest.name] = manifest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
scenarios: dict[str, ScenarioConfig] = {}
|
| 151 |
scenarios_dir = root / "scenarios"
|
|
@@ -161,6 +176,7 @@ class Registry:
|
|
| 161 |
raw_models = _resolve_model_endpoints(raw_models)
|
| 162 |
models = ModelsConfig.model_validate(raw_models)
|
| 163 |
|
|
|
|
| 164 |
return cls(agents=agents, scenarios=scenarios, models=models)
|
| 165 |
|
| 166 |
@classmethod
|
|
@@ -214,6 +230,13 @@ class Registry:
|
|
| 214 |
|
| 215 |
memory_index = memory_index_from_env()
|
| 216 |
agents = tuple(self.build_agent(agent_name, router, tools, memory_index) for agent_name in cfg.cast)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
return Scenario(
|
| 218 |
name=cfg.name,
|
| 219 |
default_seed=cfg.default_seed,
|
|
@@ -227,6 +250,15 @@ class Registry:
|
|
| 227 |
"""Build a Governor from a scenario's budget config (or defaults)."""
|
| 228 |
cfg = self.scenarios.get(name)
|
| 229 |
budget = (cfg.governor if cfg else None) or GovernorConfig()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
return Governor(
|
| 231 |
max_turns=budget.max_turns,
|
| 232 |
max_calls_per_turn=budget.max_calls_per_turn,
|
|
|
|
| 21 |
|
| 22 |
import yaml
|
| 23 |
|
| 24 |
+
from src import observability as obs
|
| 25 |
from src.agents.base import Agent, ManifestAgent
|
| 26 |
from src.core.config import (
|
| 27 |
GovernorConfig,
|
|
|
|
| 104 |
cfg.setdefault("model", binding["model"])
|
| 105 |
cfg.setdefault("base_url", binding["base_url"])
|
| 106 |
cfg.setdefault("api_key", binding["api_key"])
|
| 107 |
+
obs.log(
|
| 108 |
+
"registry.model_endpoint",
|
| 109 |
+
profile=str(profile),
|
| 110 |
+
model=cfg.get("model", ""),
|
| 111 |
+
base_url=cfg.get("base_url", ""),
|
| 112 |
+
) # never logs api_key
|
| 113 |
return raw_models
|
| 114 |
|
| 115 |
|
|
|
|
| 153 |
for path in sorted(agents_dir.glob("*.yaml")):
|
| 154 |
manifest = validate_agent(yaml.safe_load(path.read_text()) or {})
|
| 155 |
agents[manifest.name] = manifest
|
| 156 |
+
obs.log(
|
| 157 |
+
"manifest.loaded",
|
| 158 |
+
name=manifest.name,
|
| 159 |
+
profile=manifest.model_profile,
|
| 160 |
+
endpoint=manifest.model_endpoint or "",
|
| 161 |
+
subscribes=len(manifest.subscribes_to),
|
| 162 |
+
may_emit=list(manifest.may_emit),
|
| 163 |
+
)
|
| 164 |
|
| 165 |
scenarios: dict[str, ScenarioConfig] = {}
|
| 166 |
scenarios_dir = root / "scenarios"
|
|
|
|
| 176 |
raw_models = _resolve_model_endpoints(raw_models)
|
| 177 |
models = ModelsConfig.model_validate(raw_models)
|
| 178 |
|
| 179 |
+
obs.log("config.loaded", config_dir=str(root), agents=len(agents), scenarios=len(scenarios))
|
| 180 |
return cls(agents=agents, scenarios=scenarios, models=models)
|
| 181 |
|
| 182 |
@classmethod
|
|
|
|
| 230 |
|
| 231 |
memory_index = memory_index_from_env()
|
| 232 |
agents = tuple(self.build_agent(agent_name, router, tools, memory_index) for agent_name in cfg.cast)
|
| 233 |
+
obs.log(
|
| 234 |
+
"registry.cast_assembled",
|
| 235 |
+
scenario=cfg.name,
|
| 236 |
+
cast=list(cfg.cast),
|
| 237 |
+
count=len(cfg.cast),
|
| 238 |
+
offline=getattr(router, "offline", None),
|
| 239 |
+
)
|
| 240 |
return Scenario(
|
| 241 |
name=cfg.name,
|
| 242 |
default_seed=cfg.default_seed,
|
|
|
|
| 250 |
"""Build a Governor from a scenario's budget config (or defaults)."""
|
| 251 |
cfg = self.scenarios.get(name)
|
| 252 |
budget = (cfg.governor if cfg else None) or GovernorConfig()
|
| 253 |
+
obs.log(
|
| 254 |
+
"governor.configured",
|
| 255 |
+
scenario=name,
|
| 256 |
+
max_turns=budget.max_turns,
|
| 257 |
+
max_calls_per_turn=budget.max_calls_per_turn,
|
| 258 |
+
max_total_calls=budget.max_total_calls,
|
| 259 |
+
max_total_tokens=budget.max_total_tokens,
|
| 260 |
+
hourly_budget_usd=budget.hourly_budget_usd,
|
| 261 |
+
)
|
| 262 |
return Governor(
|
| 263 |
max_turns=budget.max_turns,
|
| 264 |
max_calls_per_turn=budget.max_calls_per_turn,
|
src/tools/registry.py
CHANGED
|
@@ -10,12 +10,14 @@ returns a JSON-serialisable dict that the calling agent folds into its event.
|
|
| 10 |
In-process callables and MCP-server-backed tools both satisfy this interface, so
|
| 11 |
swapping a local stub for a real MCP server is invisible to agents.
|
| 12 |
"""
|
|
|
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
from collections.abc import Callable
|
| 16 |
from dataclasses import dataclass
|
| 17 |
from typing import Protocol
|
| 18 |
|
|
|
|
| 19 |
from src.core.manifest import AgentManifest
|
| 20 |
|
| 21 |
|
|
@@ -93,13 +95,21 @@ class ToolRegistry:
|
|
| 93 |
precedence; otherwise the call is dispatched to the resolver if one backs
|
| 94 |
the tool. An unknown granted tool raises :class:`KeyError` as before.
|
| 95 |
"""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
In-process callables and MCP-server-backed tools both satisfy this interface, so
|
| 11 |
swapping a local stub for a real MCP server is invisible to agents.
|
| 12 |
"""
|
| 13 |
+
|
| 14 |
from __future__ import annotations
|
| 15 |
|
| 16 |
from collections.abc import Callable
|
| 17 |
from dataclasses import dataclass
|
| 18 |
from typing import Protocol
|
| 19 |
|
| 20 |
+
from src import observability as obs
|
| 21 |
from src.core.manifest import AgentManifest
|
| 22 |
|
| 23 |
|
|
|
|
| 95 |
precedence; otherwise the call is dispatched to the resolver if one backs
|
| 96 |
the tool. An unknown granted tool raises :class:`KeyError` as before.
|
| 97 |
"""
|
| 98 |
+
with obs.span("tool.call", **{"tool": tool, "mal.agent": agent_name, "tool.params": sorted(params)}):
|
| 99 |
+
if tool not in manifest.tools:
|
| 100 |
+
obs.log("tool.denied", level="warning", agent=agent_name, tool=tool, granted=list(manifest.tools))
|
| 101 |
+
raise CapabilityViolation(
|
| 102 |
+
f"{agent_name!r} is not authorised to call tool {tool!r} (granted: {manifest.tools})"
|
| 103 |
+
)
|
| 104 |
+
obs.incr("tool.calls", 1, tool=tool)
|
| 105 |
+
if tool in self._tools:
|
| 106 |
+
result = self._tools[tool].run(**params)
|
| 107 |
+
obs.log("tool.call", agent=agent_name, tool=tool, transport="in-process")
|
| 108 |
+
obs.log("tool.result", level="debug", tool=tool, result=result)
|
| 109 |
+
return result
|
| 110 |
+
if self._resolver is not None and self._resolver.has(tool):
|
| 111 |
+
result = self._resolver.call(tool, params)
|
| 112 |
+
obs.log("tool.call", agent=agent_name, tool=tool, transport="resolver")
|
| 113 |
+
obs.log("tool.result", level="debug", tool=tool, result=result)
|
| 114 |
+
return result
|
| 115 |
+
raise KeyError(f"unknown tool {tool!r} (registered: {sorted(self._tools)})")
|
src/ui/fishbowl/session.py
CHANGED
|
@@ -12,6 +12,7 @@ cast, so a session is reproducible on stage.
|
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 15 |
from src.core.conductor import Conductor
|
| 16 |
from src.core.ledger_factory import make_ledger
|
| 17 |
from src.core.manifest import AgentManifest
|
|
@@ -49,21 +50,33 @@ class FishbowlSession:
|
|
| 49 |
governor=self._registry.governor_for(scenario_name),
|
| 50 |
ledger=make_ledger(),
|
| 51 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
# ββ lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 54 |
|
| 55 |
def reset(self, seed: str = "") -> None:
|
|
|
|
| 56 |
self.conductor.reset(seed or self.scenario.default_seed)
|
| 57 |
|
| 58 |
def step(self, n_ticks: int = 1) -> None:
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
def step_one(self) -> bool:
|
| 62 |
"""Advance a single agent (streaming): one event per call so the Show reveals
|
| 63 |
each mind the moment it responds, not after the whole turn finishes."""
|
| 64 |
-
|
|
|
|
| 65 |
|
| 66 |
def inject(self, text: str, label: str | None = None) -> None:
|
|
|
|
|
|
|
| 67 |
self.conductor.inject_user_event(text, label=label)
|
| 68 |
self.conductor.step()
|
| 69 |
|
|
|
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
from src import observability as obs
|
| 16 |
from src.core.conductor import Conductor
|
| 17 |
from src.core.ledger_factory import make_ledger
|
| 18 |
from src.core.manifest import AgentManifest
|
|
|
|
| 50 |
governor=self._registry.governor_for(scenario_name),
|
| 51 |
ledger=make_ledger(),
|
| 52 |
)
|
| 53 |
+
obs.log(
|
| 54 |
+
"session.created",
|
| 55 |
+
scenario=scenario_name,
|
| 56 |
+
ledger=type(self.conductor.ledger).__name__,
|
| 57 |
+
cast=len(scenario.agents),
|
| 58 |
+
)
|
| 59 |
|
| 60 |
# ββ lifecycle βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
|
| 62 |
def reset(self, seed: str = "") -> None:
|
| 63 |
+
obs.log("session.reset", scenario=self._scenario_name, seed=seed or self.scenario.default_seed)
|
| 64 |
self.conductor.reset(seed or self.scenario.default_seed)
|
| 65 |
|
| 66 |
def step(self, n_ticks: int = 1) -> None:
|
| 67 |
+
with obs.span("session.step", **{"session.n_ticks": n_ticks}):
|
| 68 |
+
obs.log("session.step", n_ticks=n_ticks)
|
| 69 |
+
self.conductor.step(n_ticks)
|
| 70 |
|
| 71 |
def step_one(self) -> bool:
|
| 72 |
"""Advance a single agent (streaming): one event per call so the Show reveals
|
| 73 |
each mind the moment it responds, not after the whole turn finishes."""
|
| 74 |
+
with obs.span("session.step", **{"session.streaming": True}):
|
| 75 |
+
return self.conductor.step_one()
|
| 76 |
|
| 77 |
def inject(self, text: str, label: str | None = None) -> None:
|
| 78 |
+
obs.log("session.inject", label=label or "", chars=len(text))
|
| 79 |
+
obs.log("session.inject.text", level="debug", label=label or "", text=text)
|
| 80 |
self.conductor.inject_user_event(text, label=label)
|
| 81 |
self.conductor.step()
|
| 82 |
|