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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- 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
|