activegraph 1.0.0rc2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,205 @@
1
+ """Structured logging. CONTRACT v0.8 #6–#7, #16.
2
+
3
+ The framework emits structured logs through stdlib ``logging``. We do
4
+ NOT auto-configure on import — a library that does is hostile. By
5
+ default the framework attaches to ``logging.getLogger("activegraph")``
6
+ and lets the operator's config handle output.
7
+
8
+ If the operator wants the opinionated setup, ``configure_logging`` adds
9
+ a JSON-line handler with the documented schema.
10
+
11
+ The schema is the operator contract. Dashboards built against these
12
+ field names keep working across framework versions.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import sys
20
+ import time
21
+ from typing import Any, Callable, Optional
22
+
23
+
24
+ LOGGER_ROOT = "activegraph"
25
+
26
+ # The documented operator-facing log schema. Fields appear when
27
+ # applicable; fields that don't apply are omitted (not nulled).
28
+ LOG_FIELDS: tuple[str, ...] = (
29
+ "timestamp",
30
+ "level",
31
+ "logger",
32
+ "message",
33
+ "run_id",
34
+ "event_id",
35
+ "behavior",
36
+ "tool",
37
+ "model",
38
+ "cache_hit",
39
+ "cost_usd",
40
+ "latency_seconds",
41
+ "reason",
42
+ "error_type",
43
+ "error_message",
44
+ )
45
+
46
+ # Reserved attributes on every LogRecord (stdlib internals). Any
47
+ # `extra=` field that collides is renamed in extras-pickup logic below.
48
+ _RESERVED_RECORD_ATTRS = frozenset(
49
+ {
50
+ "args", "asctime", "created", "exc_info", "exc_text", "filename",
51
+ "funcName", "levelname", "levelno", "lineno", "message", "module",
52
+ "msecs", "msg", "name", "pathname", "process", "processName",
53
+ "relativeCreated", "stack_info", "thread", "threadName",
54
+ "taskName", # 3.12+
55
+ }
56
+ )
57
+
58
+
59
+ _payload_redactor_state: dict[str, Optional[Callable[[dict], dict]]] = {
60
+ "fn": None
61
+ }
62
+
63
+
64
+ def set_payload_redactor(fn: Optional[Callable[[dict], dict]]) -> None:
65
+ """Install a redactor that runs on any payload before it enters a log
66
+ record's ``extra`` dict. Idempotent. Pass None to remove.
67
+ """
68
+ _payload_redactor_state["fn"] = fn
69
+
70
+
71
+ def redact_payload(payload: dict) -> dict:
72
+ """Apply the configured redactor (or identity)."""
73
+ fn = _payload_redactor_state["fn"]
74
+ return fn(payload) if fn is not None else payload
75
+
76
+
77
+ class JsonLineFormatter(logging.Formatter):
78
+ """One JSON object per log record, on one line.
79
+
80
+ Populates the documented LOG_FIELDS from the record's reserved
81
+ attributes plus any ``extra=`` dict the caller passed. Omits fields
82
+ that aren't present.
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ super().__init__()
87
+
88
+ def format(self, record: logging.LogRecord) -> str:
89
+ out: dict[str, Any] = {
90
+ "timestamp": _iso_utc(record.created),
91
+ "level": record.levelname,
92
+ "logger": record.name,
93
+ "message": record.getMessage(),
94
+ }
95
+ # Pluck extras: any record attribute not in the reserved set is
96
+ # treated as a documented field. We only emit fields documented
97
+ # in LOG_FIELDS so the schema is stable.
98
+ for k in LOG_FIELDS:
99
+ if k in out:
100
+ continue
101
+ v = getattr(record, k, _MISSING)
102
+ if v is _MISSING or v is None:
103
+ continue
104
+ out[k] = v
105
+ # Allow exc_info to flow into error_type / error_message even if
106
+ # the caller didn't pass them explicitly.
107
+ if record.exc_info and "error_type" not in out:
108
+ etype, evalue, _ = record.exc_info
109
+ if etype is not None:
110
+ out["error_type"] = etype.__name__
111
+ if evalue is not None and "error_message" not in out:
112
+ out["error_message"] = str(evalue)
113
+ return json.dumps(out, separators=(",", ":"), ensure_ascii=False)
114
+
115
+
116
+ class _MISSING_T: # sentinel
117
+ pass
118
+
119
+
120
+ _MISSING = _MISSING_T()
121
+
122
+
123
+ def _iso_utc(ts: float) -> str:
124
+ # Avoid datetime import overhead per record.
125
+ lt = time.gmtime(ts)
126
+ ms = int((ts - int(ts)) * 1000)
127
+ return (
128
+ f"{lt.tm_year:04d}-{lt.tm_mon:02d}-{lt.tm_mday:02d}T"
129
+ f"{lt.tm_hour:02d}:{lt.tm_min:02d}:{lt.tm_sec:02d}.{ms:03d}Z"
130
+ )
131
+
132
+
133
+ def get_logger(name: str = LOGGER_ROOT) -> logging.Logger:
134
+ """Get a logger in the activegraph namespace.
135
+
136
+ ``get_logger("runtime")`` → ``logging.getLogger("activegraph.runtime")``.
137
+ ``get_logger("activegraph")`` → root activegraph logger.
138
+ """
139
+ if name == LOGGER_ROOT or name.startswith(LOGGER_ROOT + "."):
140
+ return logging.getLogger(name)
141
+ return logging.getLogger(f"{LOGGER_ROOT}.{name}")
142
+
143
+
144
+ def runtime_log_extra(**fields: Any) -> dict[str, Any]:
145
+ """Build an ``extra=`` dict for a log call, dropping None values and
146
+ rejecting reserved LogRecord attribute names.
147
+
148
+ Use:
149
+ log.info("event emitted", extra=runtime_log_extra(
150
+ run_id=rt.run_id, event_id=e.id, behavior=b.name,
151
+ ))
152
+ """
153
+ out: dict[str, Any] = {}
154
+ for k, v in fields.items():
155
+ if v is None:
156
+ continue
157
+ if k in _RESERVED_RECORD_ATTRS:
158
+ # Don't smash stdlib internals; prefix the key.
159
+ out[f"ag_{k}"] = v
160
+ continue
161
+ out[k] = v
162
+ return out
163
+
164
+
165
+ def configure_logging(
166
+ level: str | int = "INFO",
167
+ *,
168
+ json_output: bool = True,
169
+ stream: Any = None,
170
+ payload_redactor: Optional[Callable[[dict], dict]] = None,
171
+ ) -> logging.Logger:
172
+ """Configure the activegraph logger hierarchy.
173
+
174
+ Idempotent: repeated calls replace the existing handler rather than
175
+ stacking. Returns the activegraph root logger.
176
+
177
+ Args:
178
+ level: numeric or string level name.
179
+ json_output: True for the documented JSON-line format; False for
180
+ the stdlib default (one human-readable line).
181
+ stream: where to write. Defaults to stderr (the logging default).
182
+ payload_redactor: optional callable(dict) -> dict applied to any
183
+ payload before it's added to a log record's extra fields.
184
+ """
185
+ set_payload_redactor(payload_redactor)
186
+ logger = logging.getLogger(LOGGER_ROOT)
187
+ logger.setLevel(level)
188
+ # Replace existing activegraph handlers so calls are idempotent.
189
+ for h in list(logger.handlers):
190
+ if getattr(h, "_activegraph", False):
191
+ logger.removeHandler(h)
192
+ handler = logging.StreamHandler(stream or sys.stderr)
193
+ handler._activegraph = True # type: ignore[attr-defined]
194
+ handler.setFormatter(
195
+ JsonLineFormatter()
196
+ if json_output
197
+ else logging.Formatter(
198
+ "%(asctime)s %(levelname)s %(name)s %(message)s"
199
+ )
200
+ )
201
+ logger.addHandler(handler)
202
+ # Don't propagate up to root if we own a handler — operators with
203
+ # their own root handler would double-print.
204
+ logger.propagate = False
205
+ return logger
@@ -0,0 +1,219 @@
1
+ """Metrics protocol + NoOp default. CONTRACT v0.8 #8–#10.
2
+
3
+ Three methods. No timers (use a histogram with a latency value), no
4
+ summaries (Prometheus-specific), no custom types. Adding a metric is
5
+ a public API change — the standard metric list below is the operator
6
+ contract.
7
+
8
+ Cardinality rule (locked, CONTRACT v0.8 #C4):
9
+
10
+ run_id MAY appear as a tag on gauges of active state (cardinality
11
+ is bounded by the number of concurrently active runs).
12
+ run_id MUST NOT appear as a tag on counters or histograms.
13
+
14
+ The ``METRIC_NAMES`` table enforces this at import time — any standard
15
+ metric whose tag set violates the rule fails the test.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+ from typing import Protocol, runtime_checkable
22
+
23
+
24
+ # ---- the protocol --------------------------------------------------------
25
+
26
+
27
+ @runtime_checkable
28
+ class Metrics(Protocol):
29
+ """Three methods, all best-effort, all non-throwing.
30
+
31
+ Implementations MUST tolerate unknown metric names. Unknown tag keys
32
+ are also accepted; cardinality discipline is the caller's job.
33
+ """
34
+
35
+ def counter(self, name: str, tags: dict[str, str], value: float = 1.0) -> None: ...
36
+ def histogram(self, name: str, tags: dict[str, str], value: float) -> None: ...
37
+ def gauge(self, name: str, tags: dict[str, str], value: float) -> None: ...
38
+
39
+
40
+ # ---- the no-op default ---------------------------------------------------
41
+
42
+
43
+ class NoOpMetrics:
44
+ """Default Metrics implementation. Does nothing.
45
+
46
+ Three method bodies, each a single ``return``. The runtime is fully
47
+ functional with NoOpMetrics. Profile-checked for zero allocation
48
+ pressure under steady load.
49
+ """
50
+
51
+ __slots__ = ()
52
+
53
+ def counter(self, name: str, tags: dict[str, str], value: float = 1.0) -> None:
54
+ return
55
+
56
+ def histogram(self, name: str, tags: dict[str, str], value: float) -> None:
57
+ return
58
+
59
+ def gauge(self, name: str, tags: dict[str, str], value: float) -> None:
60
+ return
61
+
62
+
63
+ # ---- the documented metric table ----------------------------------------
64
+
65
+ # Type tags: c=counter, h=histogram, g=gauge.
66
+ # Source of truth for the standard metric list. Conformance tests pin
67
+ # this table; adding/removing a row is a public API change.
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class MetricSpec:
72
+ name: str
73
+ kind: str # "counter" | "histogram" | "gauge"
74
+ tags: tuple[str, ...]
75
+ description: str
76
+
77
+
78
+ METRIC_NAMES: tuple[MetricSpec, ...] = (
79
+ MetricSpec(
80
+ "activegraph_events_emitted_total",
81
+ "counter",
82
+ ("event_type",),
83
+ "Every event that lands in the graph's event log.",
84
+ ),
85
+ MetricSpec(
86
+ "activegraph_behaviors_invoked_total",
87
+ "counter",
88
+ ("behavior",),
89
+ "Each behavior invocation. Increments before the handler runs.",
90
+ ),
91
+ MetricSpec(
92
+ "activegraph_behaviors_failed_total",
93
+ "counter",
94
+ ("behavior", "reason"),
95
+ "Behavior invocations that produced a behavior.failed event.",
96
+ ),
97
+ MetricSpec(
98
+ "activegraph_behaviors_duration_seconds",
99
+ "histogram",
100
+ ("behavior",),
101
+ "Wall-clock duration of a behavior invocation (handler only).",
102
+ ),
103
+ MetricSpec(
104
+ "activegraph_llm_calls_total",
105
+ "counter",
106
+ ("model",),
107
+ "Every llm.requested event (cached and non-cached).",
108
+ ),
109
+ MetricSpec(
110
+ "activegraph_llm_cache_hits_total",
111
+ "counter",
112
+ ("model",),
113
+ "LLM calls served from the recorded-response cache.",
114
+ ),
115
+ MetricSpec(
116
+ "activegraph_llm_failed_total",
117
+ "counter",
118
+ ("model", "reason"),
119
+ "LLM calls that failed before producing a usable response.",
120
+ ),
121
+ MetricSpec(
122
+ "activegraph_llm_tokens_in",
123
+ "histogram",
124
+ ("model",),
125
+ "Input tokens reported by the provider per llm.responded.",
126
+ ),
127
+ MetricSpec(
128
+ "activegraph_llm_tokens_out",
129
+ "histogram",
130
+ ("model",),
131
+ "Output tokens reported by the provider per llm.responded.",
132
+ ),
133
+ MetricSpec(
134
+ "activegraph_llm_cost_usd",
135
+ "histogram",
136
+ ("model",),
137
+ "Per-call cost in USD as reported by the provider.",
138
+ ),
139
+ MetricSpec(
140
+ "activegraph_tools_calls_total",
141
+ "counter",
142
+ ("tool",),
143
+ "Every tool.requested event (cached and non-cached).",
144
+ ),
145
+ MetricSpec(
146
+ "activegraph_tools_cache_hits_total",
147
+ "counter",
148
+ ("tool",),
149
+ "Tool calls served from the recorded-response cache.",
150
+ ),
151
+ MetricSpec(
152
+ "activegraph_tools_failed_total",
153
+ "counter",
154
+ ("tool", "reason"),
155
+ "Tool calls that produced a tool.failed event.",
156
+ ),
157
+ MetricSpec(
158
+ "activegraph_tools_duration_seconds",
159
+ "histogram",
160
+ ("tool",),
161
+ "Wall-clock duration of a tool invocation.",
162
+ ),
163
+ MetricSpec(
164
+ "activegraph_queue_depth",
165
+ "gauge",
166
+ (),
167
+ "Current depth of the runtime's event queue.",
168
+ ),
169
+ MetricSpec(
170
+ "activegraph_budget_cost_remaining_usd",
171
+ "gauge",
172
+ ("run_id",),
173
+ "Remaining cost budget for an active run, USD.",
174
+ ),
175
+ MetricSpec(
176
+ "activegraph_budget_events_remaining",
177
+ "gauge",
178
+ ("run_id",),
179
+ "Remaining event budget for an active run.",
180
+ ),
181
+ MetricSpec(
182
+ "activegraph_patterns_evaluated_total",
183
+ "counter",
184
+ (),
185
+ "Pattern evaluations across all behaviors.",
186
+ ),
187
+ MetricSpec(
188
+ "activegraph_patterns_evaluation_duration_seconds",
189
+ "histogram",
190
+ (),
191
+ "Per-evaluation duration of a pattern subscription.",
192
+ ),
193
+ MetricSpec(
194
+ "activegraph_replay_divergence_detected_total",
195
+ "counter",
196
+ ("reason",),
197
+ "Replay-strict re-runs that diverged from the recorded log.",
198
+ ),
199
+ )
200
+
201
+ METRIC_BY_NAME: dict[str, MetricSpec] = {m.name: m for m in METRIC_NAMES}
202
+
203
+
204
+ def validate_cardinality_rule(metrics: tuple[MetricSpec, ...] = METRIC_NAMES) -> None:
205
+ """Enforce CONTRACT v0.8 #C4: run_id only appears on gauges.
206
+
207
+ Called from the conformance test. Raises if any counter or histogram
208
+ declares run_id as a tag.
209
+ """
210
+ for spec in metrics:
211
+ if "run_id" in spec.tags and spec.kind != "gauge":
212
+ raise AssertionError(
213
+ f"metric {spec.name!r} ({spec.kind}) lists run_id as a tag — "
214
+ f"forbidden by the cardinality rule (run_id is gauge-only)."
215
+ )
216
+
217
+
218
+ # Validate at import time so any in-tree edit fails loud.
219
+ validate_cardinality_rule()