Skip to content

Commit 9f22175

Browse files
dmealingclaude
andcommitted
feat(python): serve the shipped ai library, so the trace generator can load its input
The Python port shipped both halves of the AI trace stack and not the metadata in between. `runtime/llm_recorder.py` was there. The `trace-helper` generator was there and registered for the CLI. But there was no `library/` package and no `libraries` loader option, so `metaobjects::ai::LlmCallBase` could not be loaded on this port at all: the documented `extends: metaobjects::ai::LlmCallBase` failed with ERR_UNRESOLVED_SUPER. A generator shipped without its input. The more useful part is why nothing caught it. The codegen suite hand-builds its own abstract `LlmCallBase` — exactly what ADR-0024 already recorded ("the green tests pass only because they bypass the shipped base with bespoke entities"). A test that builds its own fixture proves nothing about the path an adopter follows, so the port's docs could describe a path that could not run. No new vocabulary — no type, subtype, or attribute — so expected-registry.json is untouched. This mirrors the TypeScript design rather than inventing a second one: same package names, same refs (path under library/ minus .yaml), same on-disk-first order. library/ library_sources(packages) returns a FileSource when the repo-root library/ tree is reachable, so editing the canonical YAML takes effect immediately, and falls back to the generated embed otherwise (the wheel-in-site-packages case). scripts/ regenerates the embed. Embedded as a .py module, not shipped as package data, so no build-backend config can silently drop it. libraries=[...] on from_directory (hence load_directory). Opt-in and lazily imported: a load requesting no libraries neither pays the import nor gets extra names in its model. Sources are prepended for a deterministic, TS-matching order — NOT because resolution needs it; resolve_supers runs once after every root merges. config `libraries` threaded into the CLI's load path. The option first landed only on the loader — which is also all TypeScript exposes — so `metaobjects gen` still could not load the metadata the registered trace-helper generator exists to consume. The generator was reachable from the command line while its input was not. The TS CLI still lacks the key; that is a parity follow-up, not drift introduced here. An unknown package name is a ConfigError naming the valid ones when it comes from a config file, and a silent skip when it comes through the API — matching TypeScript. A name a human typed is a mistake worth failing on; an API caller asking for a package this version does not ship should still load its own metadata. Four gates ship with it, because each of these failed silently before: * the embed is byte-compared against the canonical YAML (the drift pattern already used for spec/metamodel/), so a stale generated module cannot reach a wheel; * `extends` is asserted to FAIL without the opt-in and resolve 18 inherited fields with it — the negative half is what proves the opt-in is doing the work; * ADR-0024 FIX #1 is now enforced: build_llm_call_row's keys equal LlmCallBase's effective fields, both directions. The ADR asked for this gate; it did not exist; * the acceptance test RUNS the generated helper against a capturing recorder and asserts every key it writes is a field the entity declares. The first version of that test asserted the strings voRequest/voResponse appeared in the emitted source, against a fixture declaring neither column — so it passed while blessing a helper that raises on its first write. That is the same bypass ADR-0024 warns about, reappearing inside a test written to prevent it. A substring assertion over generated code is not an end-to-end test. Not addressed, and documented rather than changed: the opt-in also brings the library's own concrete LlmCall entity (table llm_call) alongside the abstract base, so it appears in codegen output and in a schema diff unless filtered. library/ai/llm-call.yaml is shared by every port, so splitting it is a cross-port decision. 1639 tests pass; ruff clean on the changed files; no new mypy errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018k3CqBZLFkbP4Qs4FZhh96
1 parent 6c9a39f commit 9f22175

10 files changed

Lines changed: 693 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,78 @@ ADR-0015 makes for schema migrations. Refusing warns rather than failing the rea
482482
because failing a Maven build over a file the user chose to own would punish exactly the
483483
person the guard protects.
484484

485+
||||||| constructed merge base
486+
487+
### Added — Python port serves the shipped `ai` library (loader `libraries=[...]`)
488+
489+
**No new vocabulary — this is port parity.** No type, subtype, or attribute is added, so
490+
`expected-registry.json` is untouched.
491+
492+
The Python port shipped both halves of the AI trace stack *except* the metadata they
493+
operate on. `runtime/llm_recorder.py` (`build_llm_call_row` / `persist_llm_call_row`) and
494+
the registered `trace-helper` generator were both present, but there was no `library/`
495+
package and no `libraries` loader option — so `metaobjects::ai::LlmCallBase` could not be
496+
loaded on this port at all, and the documented `extends: metaobjects::ai::LlmCallBase`
497+
failed with `ERR_UNRESOLVED_SUPER`. A generator shipped without its input.
498+
499+
That is the more useful lesson: the feature was complete on both sides of the metadata
500+
and absent in the middle, and nothing failed loudly, because *nothing could author the
501+
entity that would have exercised it*. The port's own docs described the path as working.
502+
503+
Mirrors the TypeScript design rather than inventing a second one — same package names,
504+
same refs (path under `library/` minus `.yaml`), same on-disk-first resolution:
505+
506+
- `metaobjects/library/``library_sources(packages)` returns a `FileSource` when the
507+
repo-root `library/` tree is reachable (a checkout, so editing the canonical YAML takes
508+
effect immediately) and falls back to the generated embed otherwise (the ordinary
509+
wheel-in-site-packages case). An unrecognised package name contributes no sources
510+
rather than raising: asking for a package this version does not ship must not stop a
511+
consumer loading its own metadata.
512+
- `scripts/generate_embedded_library.py` — regenerates the embed from the canonical
513+
repo-root YAML. Embedded as a `.py` module, not shipped as package data, so no build
514+
backend configuration can silently drop it.
515+
- `MetaDataLoader.from_directory(..., libraries=["ai"])` (hence `load_directory`) —
516+
opt-in, and imported lazily: a load that requests no libraries neither pays the import
517+
nor gets extra names in its model. Sources are prepended for a deterministic,
518+
TS-matching order, **not** because resolution needs it — `resolve_supers` runs once
519+
after every root merges, so appending resolves identically.
520+
- **A `libraries` key on `metaobjects.config.yaml`, threaded into the CLI's load path.**
521+
The option first landed only on the loader — which is also all TypeScript exposes — so
522+
`metaobjects gen` still could not load `metaobjects::ai::LlmCallBase` even though the
523+
`trace-helper` generator that consumes it is registered *for the CLI*. The generator was
524+
reachable from the command line while its input was not. An unknown package name in the
525+
config is a `ConfigError` naming the valid ones, while the programmatic API keeps
526+
TypeScript's silent skip: a name typed into a config file is a mistake worth failing on,
527+
where an API caller asking for a package this version does not ship should still load
528+
its own metadata. (The TypeScript CLI still lacks the key — parity follow-up, not drift
529+
introduced here.)
530+
531+
Three gates ship with it, since each of these failed silently before:
532+
533+
- the embed is byte-compared against the canonical YAML (the drift pattern already used
534+
for `spec/metamodel/`), so a stale generated module cannot reach a wheel;
535+
- `extends: metaobjects::ai::LlmCallBase` is asserted to fail *without* the opt-in and to
536+
resolve 18 inherited fields *with* it — the negative half is what proves the opt-in is
537+
doing the work;
538+
- **ADR-0024 FIX #1 is now enforced**: `build_llm_call_row`'s keys are asserted equal to
539+
`LlmCallBase`'s effective fields, both directions. The ADR asked for this gate; it did
540+
not exist. A recorder writing an undeclared column fails at persist with "Unknown
541+
field", and the two drifting apart is invisible until then.
542+
- the acceptance test **runs** the generated helper against a capturing recorder and
543+
asserts every key it writes is a field the entity declares. Worth stating why: the first
544+
version of that test asserted the strings `voRequest`/`voResponse` appeared in the
545+
emitted source, against a fixture declaring neither column — so it passed while blessing
546+
a helper that raises on its first write. That is the same bypass ADR-0024 already warns
547+
about ("the green tests pass only because they bypass the shipped base with bespoke
548+
entities"), reappearing one level up in a test written to prevent it. A substring
549+
assertion over generated code is not an end-to-end test.
550+
551+
Not addressed here, and worth knowing before adopting: the `ai` opt-in also brings the
552+
library's own **concrete** `LlmCall` entity (table `llm_call`) in alongside the abstract
553+
base, so it appears in codegen output and in a schema diff unless filtered. Documented in
554+
the Python prompts reference rather than changed, because `library/ai/llm-call.yaml` is
555+
shared by every port and splitting it is a cross-port decision.
556+
485557
## [0.23.2] — npm `0.23.2` · PyPI `0.23.2` · NuGet `0.23.2` · Maven `7.23.2`
486558

487559
A coordinated **PATCH** across all four registries.

agent-context/skills/metaobjects-prompts/references/python.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,41 @@ one boundary, not both.
119119
> generator emits a `record_<entity>(recorder, input, redact=None)` helper (per
120120
> concrete entity extending `LlmCallBase` with a `@responseRef`/`@payloadRef`-carrying
121121
> `template.prompt`) that tolerantly extracts the typed response, builds the base
122-
> trace row, and persists it once. What's still TS-only is the **`call<Entity>`
122+
> trace row, and persists it once.
123+
>
124+
> `LlmCallBase` is shipped metadata, not something you author: opt in when you load,
125+
> then inherit from it. Without the opt-in the `extends` fails with
126+
> `ERR_UNRESOLVED_SUPER`.
127+
>
128+
> ```python
129+
> load_directory("metadata/", libraries=["ai"]) # metaobjects::ai::LlmCallBase
130+
> ```
131+
> For `metaobjects gen` / `verify`, declare it in `metaobjects.config.yaml` instead —
132+
> the CLI reads the same opt-in from there:
133+
> ```yaml
134+
> metadata: metadata/
135+
> libraries: ["ai"]
136+
> ```
137+
> ```yaml
138+
> - object.entity:
139+
> name: AssistantCall
140+
> extends: metaobjects::ai::LlmCallBase
141+
> children:
142+
> - source.rdb: { table: assistant_call, role: primary }
143+
> - identity.primary: { name: id, fields: ["spanId"] }
144+
> # Typed columns are AUTHORED, never derived (ADR-0024 amendment).
145+
> # Declare BOTH: the generated record_<entity> writes voRequest and
146+
> # voResponse unconditionally, and any key the entity does not declare
147+
> # raises "no field '<name>' in metadata" on the first persist.
148+
> - field.object: { name: voRequest, objectRef: MyRequestVO, storage: jsonb }
149+
> - field.object: { name: voResponse, objectRef: MyResponseVO, storage: jsonb }
150+
> ```
151+
>
152+
> Note the opt-in also brings in the library's own concrete `LlmCall` entity
153+
> (table `llm_call`) alongside the abstract base — it will appear in codegen output
154+
> and in a schema diff unless you filter it.
155+
>
156+
> What's still TS-only is the **`call<Entity>`
123157
> render→call→record convenience loop** — Python intentionally does not emit it,
124158
> because the `LlmClient` seam it wraps is BYO / vendor-neutral here (ADR-0024). So
125159
> you compose render → your LLM call → the generated `record_<entity>(...)` yourself;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env python3
2+
"""Regenerate ``metaobjects/library/embedded_library.py`` from the repo-root ``library/``.
3+
4+
Cross-port parity: mirrors ``scripts/generate-embedded-library.ts`` in the TypeScript
5+
port, including the ref convention (path under ``library/`` minus the ``.yaml``
6+
extension) so both ports key the same content identically.
7+
8+
Why embed at all: a wheel installed into site-packages has no repo-root ``library/``
9+
tree, and shipping the YAML as package data is one packaging-config mistake away from a
10+
silently absent library. A generated ``.py`` module is ordinary source — it cannot be
11+
dropped by a build backend.
12+
13+
Run from anywhere: python server/python/scripts/generate_embedded_library.py
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import sys
19+
from pathlib import Path
20+
21+
HEADER = '''\
22+
# AUTO-GENERATED by scripts/generate_embedded_library.py — DO NOT EDIT.
23+
# Canonical source: repo-root library/**/*.yaml
24+
# Regenerate: python server/python/scripts/generate_embedded_library.py
25+
#
26+
# Embeds the canonical library files as string literals so they resolve wherever the
27+
# on-disk library/ directory is unavailable (a wheel installed into site-packages).
28+
# Keys are refs: path under library/ minus the .yaml extension.
29+
30+
EMBEDDED_LIBRARY: dict[str, str] = {
31+
'''
32+
33+
34+
def repo_root(start: Path) -> Path:
35+
"""Walk up until a directory holds BOTH ``library/`` and ``server/``."""
36+
for candidate in [start, *start.parents]:
37+
if (candidate / "library").is_dir() and (candidate / "server").is_dir():
38+
return candidate
39+
raise SystemExit("could not locate the repo root (a dir containing library/ and server/)")
40+
41+
42+
def main() -> int:
43+
root = repo_root(Path(__file__).resolve().parent)
44+
library_dir = root / "library"
45+
out_path = root / "server/python/src/metaobjects/library/embedded_library.py"
46+
47+
entries = []
48+
for path in sorted(library_dir.rglob("*.yaml")):
49+
ref = path.relative_to(library_dir).with_suffix("").as_posix()
50+
entries.append((ref, path.read_text(encoding="utf-8")))
51+
52+
if not entries:
53+
raise SystemExit(f"no *.yaml found under {library_dir} — refusing to emit an empty library")
54+
55+
body = "".join(f" {ref!r}: {text!r},\n" for ref, text in entries)
56+
out_path.write_text(HEADER + body + "}\n", encoding="utf-8")
57+
58+
print(f"wrote {out_path.relative_to(root)} ({len(entries)} ref(s): {', '.join(r for r, _ in entries)})")
59+
return 0
60+
61+
62+
if __name__ == "__main__":
63+
sys.exit(main())

server/python/src/metaobjects/cli.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ def _load_root(
235235
metadata_dir: str,
236236
strict: bool = False,
237237
providers: list[object] | None = None,
238+
libraries: list[str] | None = None,
238239
) -> tuple[MetaData | None, list[str]]:
239240
"""Load metadata; return ``(root, error_messages)``. ``root`` is None on error.
240241
@@ -247,15 +248,26 @@ def _load_root(
247248
composed ON TOP of the core set, so a config-registered custom subtype resolves
248249
through the standalone CLI just as it does when an app loads the loader directly.
249250
Empty/None keeps the core-providers-only default (``from_directory`` convenience).
251+
252+
``libraries`` — MetaObjects-shipped library packages (config key ``libraries``,
253+
e.g. ``["ai"]``). Without this the CLI could not load ``metaobjects::ai::LlmCallBase``,
254+
so ``metaobjects gen`` failed with ERR_UNRESOLVED_SUPER on exactly the metadata the
255+
registered ``trace-helper`` generator exists to consume — the generator was reachable
256+
from the CLI while its input was not.
250257
"""
251258
if providers:
252259
from metaobjects.core_types import core_providers
253260

254261
result = MetaDataLoader.from_directory(
255-
metadata_dir, providers=[*core_providers, *providers], strict=strict
262+
metadata_dir,
263+
providers=[*core_providers, *providers],
264+
strict=strict,
265+
libraries=libraries,
256266
)
257267
else:
258-
result = MetaDataLoader.from_directory(metadata_dir, strict=strict)
268+
result = MetaDataLoader.from_directory(
269+
metadata_dir, strict=strict, libraries=libraries
270+
)
259271
if result.errors:
260272
msgs = [f"{e.code}: {e.message}" for e in result.errors]
261273
return None, msgs
@@ -671,7 +683,9 @@ def _cmd_gen_config(args: argparse.Namespace) -> int:
671683
if not providers_ok:
672684
return 1
673685

674-
root, load_errors = _load_root(config.metadata_dir(), providers=providers)
686+
root, load_errors = _load_root(
687+
config.metadata_dir(), providers=providers, libraries=config.libraries
688+
)
675689
if root is None:
676690
print("error: failed to load metadata:", file=sys.stderr)
677691
for msg in load_errors:
@@ -844,7 +858,9 @@ def _verify_codegen_config(args: argparse.Namespace) -> int:
844858
if not providers_ok:
845859
return 1
846860

847-
root, load_errors = _load_root(config.metadata_dir(), strict=strict, providers=providers)
861+
root, load_errors = _load_root(
862+
config.metadata_dir(), strict=strict, providers=providers, libraries=config.libraries
863+
)
848864
if root is None:
849865
print("error: failed to load metadata:", file=sys.stderr)
850866
for msg in load_errors:

server/python/src/metaobjects/codegen/project_config.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ class ProjectConfig:
7171
metadata: str
7272
#: Consumer provider refs (``module:symbol``), resolved config-relative.
7373
providers: list[str]
74+
#: MetaObjects-shipped library packages to load alongside ``metadata``
75+
#: (e.g. ``["ai"]`` for ``metaobjects::ai::LlmCallBase``). Without this the
76+
#: CLI cannot load the metadata that shipped generators like ``trace-helper``
77+
#: exist to consume, and an adopter's ``extends`` fails ERR_UNRESOLVED_SUPER.
78+
libraries: list[str]
7479
#: Ordered run-specs (YAML map insertion order preserved).
7580
targets: list[TargetConfig]
7681

@@ -126,6 +131,22 @@ def load_project_config(path: Path) -> ProjectConfig:
126131
raise ConfigError(f"{path}: 'metadata' must be a string (a directory path).")
127132

128133
providers = _require_str_list(raw.get("providers", []), f"{path}: 'providers'")
134+
libraries = _require_str_list(raw.get("libraries", []), f"{path}: 'libraries'")
135+
if libraries:
136+
# Validated HERE and not in library_sources(): the programmatic API skips an
137+
# unknown package on purpose (cross-port parity with TS), but a name typed
138+
# into a config file is a mistake worth failing on — silently skipping it
139+
# resurfaces later as ERR_UNRESOLVED_SUPER pointing at the adopter's own
140+
# metadata, which is the wrong place to go looking.
141+
from metaobjects.library import known_packages
142+
143+
available = known_packages()
144+
unknown = [name for name in libraries if name not in available]
145+
if unknown:
146+
raise ConfigError(
147+
f"{path}: 'libraries' has unknown package(s) {unknown}; "
148+
f"available: {available}"
149+
)
129150

130151
targets_raw = raw.get("targets")
131152
if not isinstance(targets_raw, dict) or not targets_raw:
@@ -156,5 +177,6 @@ def load_project_config(path: Path) -> ProjectConfig:
156177
config_dir=path.parent.resolve(),
157178
metadata=metadata,
158179
providers=providers,
180+
libraries=libraries,
159181
targets=targets,
160182
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""MetaObjects-shipped standard metadata packages.
2+
3+
Adopters opt in through the loader's ``libraries=["ai"]`` option and then
4+
``extends: "metaobjects::ai::LlmCallBase"`` on their own entity.
5+
"""
6+
7+
from .library_sources import known_packages, library_sources
8+
9+
__all__ = ["known_packages", "library_sources"]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# AUTO-GENERATED by scripts/generate_embedded_library.py — DO NOT EDIT.
2+
# Canonical source: repo-root library/**/*.yaml
3+
# Regenerate: python server/python/scripts/generate_embedded_library.py
4+
#
5+
# Embeds the canonical library files as string literals so they resolve wherever the
6+
# on-disk library/ directory is unavailable (a wheel installed into site-packages).
7+
# Keys are refs: path under library/ minus the .yaml extension.
8+
9+
EMBEDDED_LIBRARY: dict[str, str] = {
10+
'ai/llm-call': '# library/ai/llm-call.yaml\n# MetaObjects-shipped standard metadata. Adopters opt in via the loader\'s\n# `libraries: ["ai"]` option, then `extends: "metaobjects::ai::LlmCallBase"`.\nmetadata:\n package: metaobjects::ai\n children:\n - object.entity:\n name: LlmCallBase\n abstract: true\n children:\n - field.uuid: { name: traceId }\n - field.uuid: { name: spanId }\n - field.uuid: { name: parentSpanId }\n - field.string: { name: sessionId }\n - field.string: { name: callType }\n - field.string: { name: system }\n - field.string: { name: requestModel }\n - field.string: { name: responseModel }\n - field.int: { name: inputTokens }\n - field.int: { name: outputTokens }\n - field.currency: { name: costMinor, currency: USD }\n - field.int: { name: latencyMs }\n - field.string: { name: finishReason }\n - field.string: { name: status }\n - field.string: { name: errorDetail }\n - field.timestamp: { name: startedAt }\n - field.string: { name: llmRequest, dbColumnType: jsonb } # generic jsonb (no objectRef)\n - field.string: { name: llmResponse, dbColumnType: jsonb }\n - object.entity:\n name: LlmCall\n extends: metaobjects::ai::LlmCallBase\n children:\n - source.rdb: { table: llm_call, role: primary }\n - identity.primary: { name: id, fields: ["spanId"] }\n',
11+
}

0 commit comments

Comments
 (0)