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,317 @@
1
+ """`activegraph pack new <name>` scaffolding. CONTRACT v0.9 #14.
2
+
3
+ Generates a runnable Python package layout that:
4
+ - declares activegraph as a dependency
5
+ - registers itself under the activegraph.packs entry point
6
+ - has stubs for object types, behaviors, tools, settings
7
+ - has a smoke test that imports the pack and verifies no global
8
+ registry side effects, then loads it into a fresh runtime
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+
16
+
17
+ _PACK_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
18
+
19
+
20
+ def normalize_pack_name(raw: str) -> tuple[str, str]:
21
+ """Return `(directory_name, python_module_name)`.
22
+
23
+ - kebab → snake for the module name (Python package directories
24
+ can be kebab-case for distribution but the import name is the
25
+ snake form, per packaging conventions).
26
+ - lowercases and validates.
27
+ """
28
+ name = raw.strip().lower()
29
+ if not _PACK_NAME_RE.match(name):
30
+ raise ValueError(
31
+ f"pack name {raw!r} must match [a-z][a-z0-9-]* (lowercase, ASCII)"
32
+ )
33
+ return name, name.replace("-", "_")
34
+
35
+
36
+ def scaffold_pack(target_dir: Path, raw_name: str) -> Path:
37
+ """Generate the pack at `target_dir / pack_name`. Returns the
38
+ created path. Raises FileExistsError if the directory already
39
+ exists.
40
+ """
41
+ pack_name, module_name = normalize_pack_name(raw_name)
42
+ root = target_dir / pack_name
43
+ if root.exists():
44
+ raise FileExistsError(f"{root} already exists")
45
+ root.mkdir(parents=True)
46
+ (root / module_name).mkdir()
47
+ (root / module_name / "prompts").mkdir()
48
+ (root / "tests").mkdir()
49
+
50
+ files = {
51
+ root / "pyproject.toml": _PYPROJECT_TEMPLATE.format(
52
+ pack_name=pack_name, module_name=module_name
53
+ ),
54
+ root / "README.md": _README_TEMPLATE.format(pack_name=pack_name, module_name=module_name),
55
+ root / module_name / "__init__.py": _render_init(pack_name, module_name),
56
+ root / module_name / "object_types.py": _OBJECT_TYPES_TEMPLATE,
57
+ root / module_name / "behaviors.py": _BEHAVIORS_TEMPLATE.format(
58
+ module_name=module_name
59
+ ),
60
+ root / module_name / "tools.py": _TOOLS_TEMPLATE,
61
+ root / module_name / "settings.py": _SETTINGS_TEMPLATE.format(
62
+ pack_name_title=_title(module_name)
63
+ ),
64
+ root / module_name / "prompts" / "example_prompt.md": _PROMPT_TEMPLATE,
65
+ root / "tests" / "test_pack_loads.py": _SMOKE_TEST_TEMPLATE.format(
66
+ module_name=module_name, pack_name=pack_name
67
+ ),
68
+ }
69
+ for path, content in files.items():
70
+ path.write_text(content, encoding="utf-8")
71
+ return root
72
+
73
+
74
+ # ---------------------------------------------------- templates
75
+
76
+
77
+ _PYPROJECT_TEMPLATE = """\
78
+ [build-system]
79
+ requires = ["setuptools>=68"]
80
+ build-backend = "setuptools.build_meta"
81
+
82
+ [project]
83
+ name = "{pack_name}"
84
+ version = "0.1.0"
85
+ description = "An activegraph pack."
86
+ requires-python = ">=3.11"
87
+ dependencies = ["activegraph>=0.9", "pydantic>=2"]
88
+
89
+ [project.entry-points."activegraph.packs"]
90
+ {pack_name} = "{module_name}:pack"
91
+
92
+ [tool.setuptools.packages.find]
93
+ include = ["{module_name}*"]
94
+ """
95
+
96
+
97
+ _README_TEMPLATE = """\
98
+ # {pack_name}
99
+
100
+ An [activegraph](https://github.com/yoheinakajima/activegraph) pack.
101
+
102
+ ## Install
103
+
104
+ ```sh
105
+ pip install -e .
106
+ ```
107
+
108
+ ## Use
109
+
110
+ ```python
111
+ from activegraph import Runtime, Graph
112
+ from activegraph.packs import load_by_name
113
+
114
+ rt = Runtime(Graph(), llm_provider=...)
115
+ rt.load_pack(load_by_name("{pack_name}"))
116
+ rt.run_goal("...")
117
+ ```
118
+
119
+ ## Develop
120
+
121
+ ```sh
122
+ pip install -e .[dev]
123
+ pytest
124
+ ```
125
+
126
+ Pack authoring guide:
127
+ <https://github.com/yoheinakajima/activegraph/blob/main/docs/pack_authoring.md>
128
+ """
129
+
130
+
131
+ _PACK_INIT_TEMPLATE = '''\
132
+ """{module_name} — an activegraph pack."""
133
+
134
+ from __future__ import annotations
135
+
136
+ from pathlib import Path
137
+
138
+ from activegraph.packs import Pack, load_prompts_from_dir
139
+
140
+ from {module_name}.behaviors import BEHAVIORS
141
+ from {module_name}.object_types import OBJECT_TYPES, RELATION_TYPES
142
+ from {module_name}.settings import {pack_name_title}Settings
143
+ from {module_name}.tools import TOOLS
144
+
145
+
146
+ _PROMPTS_DIR = Path(__file__).parent / "prompts"
147
+
148
+
149
+ pack = Pack(
150
+ name="{module_name}",
151
+ version="0.1.0",
152
+ description="An activegraph pack.",
153
+ object_types=OBJECT_TYPES,
154
+ relation_types=RELATION_TYPES,
155
+ behaviors=BEHAVIORS,
156
+ tools=TOOLS,
157
+ prompts=load_prompts_from_dir(_PROMPTS_DIR),
158
+ settings_schema={pack_name_title}Settings,
159
+ )
160
+
161
+
162
+ __all__ = ["pack", "{pack_name_title}Settings"]
163
+ '''
164
+
165
+
166
+ def _title(name: str) -> str:
167
+ return "".join(p.capitalize() for p in name.split("_"))
168
+
169
+
170
+ # Patch the template generation to insert the titled class name
171
+ def _render_init(pack_name: str, module_name: str) -> str:
172
+ return _PACK_INIT_TEMPLATE.format(
173
+ pack_name=pack_name,
174
+ module_name=module_name,
175
+ pack_name_title=_title(module_name),
176
+ )
177
+
178
+
179
+ _OBJECT_TYPES_TEMPLATE = '''\
180
+ """Object types and relation types declared by this pack."""
181
+
182
+ from __future__ import annotations
183
+
184
+ from pydantic import BaseModel
185
+
186
+ from activegraph.packs import ObjectType, RelationType
187
+
188
+
189
+ class Item(BaseModel):
190
+ name: str
191
+ notes: str = ""
192
+
193
+
194
+ OBJECT_TYPES = [
195
+ ObjectType(name="item", schema=Item, description="An example item."),
196
+ ]
197
+
198
+
199
+ RELATION_TYPES = [
200
+ # RelationType(name="...", source_types=("item",), target_types=("item",)),
201
+ ]
202
+ '''
203
+
204
+
205
+ _BEHAVIORS_TEMPLATE = '''\
206
+ """Pack behaviors. Decorators imported from `activegraph.packs` so
207
+ they do NOT register globally (CONTRACT v0.9 #3)."""
208
+
209
+ from __future__ import annotations
210
+
211
+ from activegraph.packs import behavior
212
+
213
+
214
+ @behavior(name="hello", on=["goal.created"])
215
+ def hello(event, graph, ctx):
216
+ """Example behavior. Replace with your own."""
217
+ graph.add_object("item", {{"name": "hello world"}})
218
+
219
+
220
+ BEHAVIORS = [hello]
221
+ '''
222
+
223
+
224
+ _TOOLS_TEMPLATE = '''\
225
+ """Pack-scoped tools. CONTRACT v0.9 #9.
226
+
227
+ Tools declared here are registered with the `{pack}.{name}` canonical
228
+ form when the pack loads. Pass `export_globally=True` to ALSO register
229
+ the short form globally.
230
+ """
231
+
232
+ from __future__ import annotations
233
+
234
+ from activegraph.packs import tool
235
+
236
+
237
+ # Add your tools here, e.g.:
238
+ # from pydantic import BaseModel
239
+ #
240
+ # class FooIn(BaseModel):
241
+ # x: str
242
+ #
243
+ # class FooOut(BaseModel):
244
+ # y: str
245
+ #
246
+ # @tool(name="foo", input_schema=FooIn, output_schema=FooOut)
247
+ # def foo(args: FooIn, ctx) -> FooOut:
248
+ # return FooOut(y=args.x.upper())
249
+
250
+
251
+ TOOLS: list = []
252
+ '''
253
+
254
+
255
+ _SETTINGS_TEMPLATE = '''\
256
+ """Settings model for the pack. Accessed by behaviors via typed
257
+ parameter injection or `ctx.settings`."""
258
+
259
+ from __future__ import annotations
260
+
261
+ from pydantic import BaseModel
262
+
263
+
264
+ class {pack_name_title}Settings(BaseModel):
265
+ """Pack settings. Add fields as needed. All fields should have
266
+ defaults so `runtime.load_pack(pack)` works without explicit
267
+ `settings=`.
268
+ """
269
+
270
+ threshold: float = 0.5
271
+ '''
272
+
273
+
274
+ _PROMPT_TEMPLATE = """---
275
+ version = "1.0.0"
276
+ ---
277
+ You are an example behavior. Replace this prompt with your own.
278
+
279
+ When wired to an `@llm_behavior` with the same `name=`, this body
280
+ becomes part of the behavior's system prompt. The runtime
281
+ auto-injects view and triggering-event blocks into the user message.
282
+
283
+ Content is hashed for replay determinism — if you edit this prompt,
284
+ the hash changes, even if you forget to bump the declared version.
285
+ """
286
+
287
+
288
+ _SMOKE_TEST_TEMPLATE = '''\
289
+ """Smoke test: the pack imports without side effects and loads cleanly."""
290
+
291
+ from __future__ import annotations
292
+
293
+ from activegraph import Graph, Runtime, clear_registry, clear_tool_registry, get_registry, get_tool_registry
294
+
295
+
296
+ def test_import_has_no_global_side_effects():
297
+ """CONTRACT v0.9 #3: pack-aware decorators don't register globally."""
298
+ clear_registry()
299
+ clear_tool_registry()
300
+ import {module_name} # noqa: F401
301
+ assert get_registry() == [], (
302
+ "importing the pack registered behaviors globally — pack code must "
303
+ "use activegraph.packs decorators, not activegraph decorators"
304
+ )
305
+ assert get_tool_registry() == [], (
306
+ "importing the pack registered tools globally"
307
+ )
308
+
309
+
310
+ def test_pack_loads_into_fresh_runtime():
311
+ from {module_name} import pack
312
+ rt = Runtime(Graph())
313
+ rt.load_pack(pack)
314
+ pack_loaded_events = [e for e in rt.graph.events if e.type == "pack.loaded"]
315
+ assert len(pack_loaded_events) == 1
316
+ assert pack_loaded_events[0].payload["name"] == "{module_name}"
317
+ '''
activegraph/policy.py ADDED
@@ -0,0 +1,20 @@
1
+ """Per-behavior policy. v0 is permissive — fields are recorded but not enforced
2
+ beyond a couple of obvious checks. Hardening lands in v0.6 alongside LLM
3
+ behaviors, where unbounded tool/cost spend is the real risk.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class Policy:
14
+ behavior: Optional[str] = None
15
+ can_create: list[str] = field(default_factory=list)
16
+ can_create_relation: list[str] = field(default_factory=list)
17
+ can_propose: list[str] = field(default_factory=list)
18
+ can_apply: list[str] = field(default_factory=list)
19
+ can_call_tool: list[str] = field(default_factory=list)
20
+ requires_approval: list[str] = field(default_factory=list)
@@ -0,0 +1 @@
1
+ """Runtime layer. Imports core/ and behaviors/."""
@@ -0,0 +1,151 @@
1
+ """Constrained graph wrapper passed to behaviors. CONTRACT #7.
2
+
3
+ Allowed methods: add_object, add_relation, patch_object, propose_patch, emit.
4
+ The wrapper stamps actor / caused_by / frame_id automatically (CONTRACT #5)
5
+ and counts mutations so the runtime can report them in behavior.completed.
6
+
7
+ Behaviors get this object as their `graph` argument — never the raw Graph.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ from activegraph.core.event import Event
15
+ from activegraph.core.graph import Graph, Object, Relation
16
+ from activegraph.core.patch import Patch
17
+
18
+
19
+ class Counters:
20
+ __slots__ = ("objects_created", "relations_created", "patches_applied", "patches_proposed", "events_emitted")
21
+
22
+ def __init__(self) -> None:
23
+ self.objects_created = 0
24
+ self.relations_created = 0
25
+ self.patches_applied = 0
26
+ self.patches_proposed = 0
27
+ self.events_emitted = 0 # user-emitted via emit(), not graph mutations
28
+
29
+
30
+ class BehaviorGraph:
31
+ def __init__(
32
+ self,
33
+ graph: Graph,
34
+ *,
35
+ actor: str,
36
+ caused_by: Optional[str],
37
+ frame_id: Optional[str],
38
+ llm_request_event_id: Optional[str] = None,
39
+ tool_request_event_ids: Optional[list[str]] = None,
40
+ ) -> None:
41
+ self._graph = graph
42
+ self._actor = actor
43
+ self._caused_by = caused_by
44
+ self._frame_id = frame_id
45
+ # CONTRACT v0.6 #15: when this BehaviorGraph was created for an
46
+ # @llm_behavior handler, every object/relation/patch it creates
47
+ # carries the originating llm.requested event id in provenance.
48
+ self._llm_request_event_id = llm_request_event_id
49
+ # CONTRACT v0.7 #19: when the LLM behavior's turn loop invoked
50
+ # tools, the tool.requested event ids are stamped into the
51
+ # provenance of every object/relation/patch the handler
52
+ # creates. Causal-chain walks can then enumerate every tool
53
+ # call that contributed to a claim.
54
+ self._tool_request_event_ids: list[str] = list(
55
+ tool_request_event_ids or []
56
+ )
57
+ self.counters = Counters()
58
+
59
+ # ---- mutators ----
60
+
61
+ def add_object(self, type: str, data: dict[str, Any]) -> Object:
62
+ obj = self._graph.add_object(
63
+ type=type,
64
+ data=data,
65
+ actor=self._actor,
66
+ caused_by=self._caused_by,
67
+ frame_id=self._frame_id,
68
+ llm_request_event_id=self._llm_request_event_id,
69
+ tool_request_event_ids=self._tool_request_event_ids,
70
+ )
71
+ self.counters.objects_created += 1
72
+ return obj
73
+
74
+ def add_relation(
75
+ self,
76
+ source: str,
77
+ target: str,
78
+ type: str,
79
+ data: Optional[dict[str, Any]] = None,
80
+ ) -> Relation:
81
+ rel = self._graph.add_relation(
82
+ source=source,
83
+ target=target,
84
+ type=type,
85
+ data=data,
86
+ actor=self._actor,
87
+ caused_by=self._caused_by,
88
+ frame_id=self._frame_id,
89
+ llm_request_event_id=self._llm_request_event_id,
90
+ tool_request_event_ids=self._tool_request_event_ids,
91
+ )
92
+ self.counters.relations_created += 1
93
+ return rel
94
+
95
+ def patch_object(self, target: str, updates: dict[str, Any]) -> Patch:
96
+ patch = self._graph.patch_object(
97
+ target=target,
98
+ updates=updates,
99
+ actor=self._actor,
100
+ caused_by=self._caused_by,
101
+ frame_id=self._frame_id,
102
+ llm_request_event_id=self._llm_request_event_id,
103
+ tool_request_event_ids=self._tool_request_event_ids,
104
+ )
105
+ self.counters.patches_applied += 1
106
+ return patch
107
+
108
+ def propose_patch(
109
+ self,
110
+ target: str,
111
+ op: str = "update",
112
+ value: Optional[dict[str, Any]] = None,
113
+ rationale: Optional[str] = None,
114
+ evidence: Optional[list[str]] = None,
115
+ ) -> Patch:
116
+ patch = self._graph.propose_patch(
117
+ target=target,
118
+ op=op,
119
+ value=value or {},
120
+ proposed_by=self._actor,
121
+ rationale=rationale,
122
+ evidence=evidence,
123
+ caused_by=self._caused_by,
124
+ frame_id=self._frame_id,
125
+ llm_request_event_id=self._llm_request_event_id,
126
+ tool_request_event_ids=self._tool_request_event_ids,
127
+ )
128
+ self.counters.patches_proposed += 1
129
+ return patch
130
+
131
+ def emit(self, event_type: str, payload: dict[str, Any]) -> Event:
132
+ ev = Event(
133
+ id=self._graph.ids.event(),
134
+ type=event_type,
135
+ payload=dict(payload),
136
+ actor=self._actor,
137
+ frame_id=self._frame_id,
138
+ caused_by=self._caused_by,
139
+ timestamp=self._graph.clock.now(),
140
+ )
141
+ self._graph.emit(ev)
142
+ self.counters.events_emitted += 1
143
+ return ev
144
+
145
+ # ---- read passthroughs (not iteration; that goes through ctx.view) ----
146
+
147
+ def get_object(self, id_: str) -> Optional[Object]:
148
+ return self._graph.get_object(id_)
149
+
150
+ def get_relation(self, id_: str) -> Optional[Relation]:
151
+ return self._graph.get_relation(id_)
@@ -0,0 +1,120 @@
1
+ """Hard limits on a run. When any limit is hit the runtime stops gracefully
2
+ and emits runtime.budget_exhausted.
3
+
4
+ CONTRACT v0.6 #9: `max_cost_usd` is tracked in `Decimal` so cents don't
5
+ drift across thousands of LLM calls. The rest of the dimensions stay
6
+ float — they're integer-shaped (event counts, behavior calls). The
7
+ pre-call cost check is conservative (uses `max_tokens` as the output
8
+ estimate) and is *only* performed when `max_cost_usd` is finite AND no
9
+ cached response was found (decision-4 adjustment).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from decimal import Decimal
16
+ from typing import Any, Optional
17
+
18
+
19
+ KNOWN_LIMITS = (
20
+ "max_events",
21
+ "max_behavior_calls",
22
+ "max_llm_calls",
23
+ "max_tool_calls",
24
+ "max_patches",
25
+ "max_depth",
26
+ "max_seconds",
27
+ "max_cost_usd",
28
+ )
29
+
30
+
31
+ def _as_decimal(v: Any) -> Decimal:
32
+ if isinstance(v, Decimal):
33
+ return v
34
+ return Decimal(str(v))
35
+
36
+
37
+ class Budget:
38
+ def __init__(self, limits: Optional[dict[str, Any]] = None) -> None:
39
+ self.limits: dict[str, float] = {}
40
+ self.cost_limit: Optional[Decimal] = None
41
+ for k in KNOWN_LIMITS:
42
+ v = (limits or {}).get(k)
43
+ if k == "max_cost_usd":
44
+ # Track cost as a Decimal so per-call sub-cent costs add
45
+ # up cleanly. None / missing → no cost ceiling.
46
+ self.cost_limit = _as_decimal(v) if v is not None else None
47
+ # Mirror as float for snapshot / has-it semantics.
48
+ self.limits[k] = (
49
+ float(self.cost_limit) if self.cost_limit is not None else float("inf")
50
+ )
51
+ else:
52
+ self.limits[k] = float(v) if v is not None else float("inf")
53
+ self.used: dict[str, float] = {k: 0.0 for k in self.limits}
54
+ self.cost_used: Decimal = Decimal("0")
55
+ self._start: Optional[float] = None
56
+ self._exhausted_by: Optional[str] = None
57
+
58
+ # ---- generic counter dimensions ----
59
+
60
+ def start(self) -> None:
61
+ self._start = time.monotonic()
62
+
63
+ def consume(self, key: str, amount: float = 1.0) -> None:
64
+ self.used[key] = self.used.get(key, 0.0) + amount
65
+
66
+ def exhausted_by(self) -> Optional[str]:
67
+ return self._exhausted_by
68
+
69
+ def remaining(self) -> bool:
70
+ for k, limit in self.limits.items():
71
+ if k == "max_seconds":
72
+ if self._start is None:
73
+ continue
74
+ if time.monotonic() - self._start >= limit:
75
+ self._exhausted_by = k
76
+ return False
77
+ continue
78
+ if k == "max_cost_usd":
79
+ if self.cost_limit is not None and self.cost_used >= self.cost_limit:
80
+ self._exhausted_by = k
81
+ return False
82
+ continue
83
+ if self.used.get(k, 0.0) >= limit:
84
+ self._exhausted_by = k
85
+ return False
86
+ return True
87
+
88
+ # ---- cost dimension (Decimal) ----
89
+
90
+ def has_cost_limit(self) -> bool:
91
+ return self.cost_limit is not None
92
+
93
+ def add_cost(self, amount: Decimal) -> None:
94
+ self.cost_used += _as_decimal(amount)
95
+ # Mirror to the float snapshot view so existing readers see it.
96
+ self.used["max_cost_usd"] = float(self.cost_used)
97
+
98
+ def cost_remaining(self, prospective_cost: Decimal) -> bool:
99
+ """Would `prospective_cost` push us past the ceiling? Returns
100
+ True if it's safe to spend, False if it would exceed."""
101
+ if self.cost_limit is None:
102
+ return True
103
+ return (self.cost_used + _as_decimal(prospective_cost)) <= self.cost_limit
104
+
105
+ def cost_remaining_amount(self) -> Optional[Decimal]:
106
+ if self.cost_limit is None:
107
+ return None
108
+ remaining = self.cost_limit - self.cost_used
109
+ return remaining if remaining > 0 else Decimal("0")
110
+
111
+ def snapshot(self) -> dict[str, Any]:
112
+ out: dict[str, Any] = {"used": dict(self.used), "limits": {}}
113
+ for k, v in self.limits.items():
114
+ out["limits"][k] = None if v == float("inf") else v
115
+ # Expose cost as Decimal-string too so consumers can choose precision.
116
+ out["cost_used_usd"] = str(self.cost_used)
117
+ out["cost_limit_usd"] = (
118
+ str(self.cost_limit) if self.cost_limit is not None else None
119
+ )
120
+ return out