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 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
- if not conductor.restore():
66
- conductor.reset(conductor.scenario.default_seed)
67
- conductor.step(n_ticks=n_ticks)
68
- ledger.close()
69
- volume.commit() # persist the ledger for the next scheduled run
70
- return {"scenario": scenario_name, "turn": conductor.turn, "stats": conductor.governor.stats}
 
 
 
 
 
 
 
 
 
 
 
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
- if tool not in manifest.tools:
97
- raise CapabilityViolation(
98
- f"{agent_name!r} is not authorised to call tool {tool!r} "
99
- f"(granted: {manifest.tools})"
100
- )
101
- if tool in self._tools:
102
- return self._tools[tool].run(**params)
103
- if self._resolver is not None and self._resolver.has(tool):
104
- return self._resolver.call(tool, params)
105
- raise KeyError(f"unknown tool {tool!r} (registered: {sorted(self._tools)})")
 
 
 
 
 
 
 
 
 
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
- self.conductor.step(n_ticks)
 
 
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
- return self.conductor.step_one()
 
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