meander-agent 0.1.0__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.
@@ -0,0 +1,47 @@
1
+ # src/meander_agent/__init__.py
2
+ """meander-agent: kundenseitiges SDK für die meander.plan-Sende-Seite.
3
+
4
+ Öffentliche Fläche ist genau das hier Re-Exportierte. Drei Schichten:
5
+ - Transport: ``init_meander(endpoint, source_key)`` -> ``MeanderClient`` (OTel +
6
+ OTLP-Export auf den meander-Ingress; der einzige nötige Aufruf ohne eigenes OTel).
7
+ - Emitter: ``attach_plan`` / ``validate_plan_shape`` (SDK-neutrale FORM-Prüfung +
8
+ Attribut-Anhang; importiert meander NICHT).
9
+ - Kern + dünne SDK-Bindings (``meander_agent.claude`` / ``meander_agent.openai``,
10
+ Extras ``[claude]`` / ``[openai]``) folgen in Block B/C.
11
+ """
12
+ from meander_agent.core import (
13
+ Run,
14
+ RunResult,
15
+ build_output_schema,
16
+ build_prompt_fragment,
17
+ meander_run,
18
+ meander_run_decorator,
19
+ )
20
+ from meander_agent.emitter import (
21
+ ORIGIN_KINDS,
22
+ PLAN_ATTRIBUTE_KEY,
23
+ PLAN_VERSION,
24
+ AttachResult,
25
+ ShapeError,
26
+ attach_plan,
27
+ validate_plan_shape,
28
+ )
29
+ from meander_agent.transport import MeanderClient, init_meander
30
+
31
+ __all__ = [
32
+ "ORIGIN_KINDS",
33
+ "PLAN_ATTRIBUTE_KEY",
34
+ "PLAN_VERSION",
35
+ "AttachResult",
36
+ "MeanderClient",
37
+ "Run",
38
+ "RunResult",
39
+ "ShapeError",
40
+ "attach_plan",
41
+ "build_output_schema",
42
+ "build_prompt_fragment",
43
+ "init_meander",
44
+ "meander_run",
45
+ "meander_run_decorator",
46
+ "validate_plan_shape",
47
+ ]
@@ -0,0 +1,101 @@
1
+ # src/meander_agent/claude.py
2
+ """Dünnes Claude-Agent-SDK-Binding auf den Kern (``meander_run`` / ``finalize``).
3
+
4
+ SDK-spezifisch ist hier NUR: der ``query()``-Aufruf mit nativer strukturierter
5
+ JSON-Ausgabe (``output_format`` = ``run.output_schema``), das Einsammeln der
6
+ terminalen ``ResultMessage`` (Run-Ende) und die Übersetzung der Claude-Semantik
7
+ (``subtype``/``is_error``) auf den neutralen ``error_state``. Baustein, Parsen,
8
+ Formprüfung, Fehler-Sperren, Anhängen und ``RunResult`` kommen ALLE aus dem Kern.
9
+
10
+ Kein meander-Import. Der Root-Span wird vom Kern-Kontextmanager gehalten; das
11
+ Binding ruft ``run.finalize(output, error_state)``, solange er offen ist.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Any
17
+
18
+ from meander_agent.core import RunResult, meander_run
19
+
20
+ # SDK-Subtypes: die native strukturierte Ausgabe meldet bei Erfolg "success",
21
+ # bei erschöpften Retries "error_max_structured_output_retries" (Beispiel).
22
+ _SUBTYPE_SUCCESS = "success"
23
+ _SUBTYPE_MAX_STRUCTURED_RETRIES = "error_max_structured_output_retries"
24
+
25
+
26
+ def _claude_error_state(
27
+ subtype: str | None, is_error: bool, errors: list[str] | None
28
+ ) -> dict | None:
29
+ """Claude-Terminal-Semantik -> neutraler error_state.
30
+
31
+ Ein Fehler-Subtype (alles außer ``None``/``"success"``) ODER ``is_error``
32
+ ergibt einen error_state (Sperre im Kern). Erfolg -> ``None``.
33
+ """
34
+ if is_error or (subtype is not None and subtype != _SUBTYPE_SUCCESS):
35
+ return {"subtype": subtype, "is_error": is_error, "errors": list(errors or [])}
36
+ return None
37
+
38
+
39
+ def _claude_output(structured_output: Any, result_text: str | None) -> Any:
40
+ """Bevorzugt die strukturierte Ausgabe (dict); sonst der reine result-Text
41
+ (den der Kern per json.loads prüft). Kein Prosa-Extrahieren."""
42
+ return structured_output if isinstance(structured_output, dict) else result_text
43
+
44
+
45
+ async def run_with_plan_async(
46
+ task: str,
47
+ *,
48
+ vocabulary: Any,
49
+ tracer: Any,
50
+ root_span_name: str = "agent.run",
51
+ options_extra: dict[str, Any] | None = None,
52
+ ) -> RunResult:
53
+ """Fährt query() mit output_format und finalisiert über den Kern.
54
+
55
+ Öffnet den Run-Root-Span (Kern), fährt den ``query()``-Async-Iterator selbst
56
+ durch, sammelt die terminale ``ResultMessage`` und ruft ``run.finalize`` —
57
+ solange der Span offen ist. Fehler werden vom Kern laut geloggt.
58
+ """
59
+ from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
60
+
61
+ with meander_run(vocabulary=vocabulary, tracer=tracer, root_span_name=root_span_name) as run:
62
+ prompt = f"{task.strip()}\n\n{run.prompt_fragment}"
63
+ opts_kwargs: dict[str, Any] = {
64
+ "output_format": {"type": "json_schema", "schema": run.output_schema}
65
+ }
66
+ if options_extra:
67
+ opts_kwargs.update(options_extra)
68
+ options = ClaudeAgentOptions(**opts_kwargs)
69
+
70
+ terminal: Any = None
71
+ async for message in query(prompt=prompt, options=options):
72
+ if isinstance(message, ResultMessage):
73
+ terminal = message # die terminale Nachricht ist verlässlich
74
+ if terminal is None:
75
+ return run.finalize(None, error_state={"errors": ["no terminal ResultMessage"]})
76
+
77
+ error_state = _claude_error_state(
78
+ terminal.subtype, getattr(terminal, "is_error", False), terminal.errors
79
+ )
80
+ output = _claude_output(terminal.structured_output, terminal.result)
81
+ return run.finalize(output, error_state)
82
+
83
+
84
+ def run_with_plan(
85
+ task: str,
86
+ *,
87
+ vocabulary: Any,
88
+ tracer: Any,
89
+ root_span_name: str = "agent.run",
90
+ options_extra: dict[str, Any] | None = None,
91
+ ) -> RunResult:
92
+ """Synchroner Wrapper um ``run_with_plan_async`` (deterministische Nutzung)."""
93
+ return asyncio.run(
94
+ run_with_plan_async(
95
+ task,
96
+ vocabulary=vocabulary,
97
+ tracer=tracer,
98
+ root_span_name=root_span_name,
99
+ options_extra=options_extra,
100
+ )
101
+ )
meander_agent/core.py ADDED
@@ -0,0 +1,379 @@
1
+ # src/meander_agent/core.py
2
+ """SDK-neutraler Adapter-Kern: Kontextmanager ``meander_run`` + ``finalize``.
3
+
4
+ Der Kern öffnet den Run-Root-Span und liefert ausschließlich SDK-neutrale
5
+ Primitive:
6
+ - ``run.prompt_fragment`` — parametrisierter Baustein (Format + erlaubtes
7
+ Vokabular), den das Binding dem SDK übergibt;
8
+ - ``run.output_schema`` — JSON-Schema der strukturierten Ausgabe (nur deklarierte
9
+ Namen), für Claudes ``output_format`` bzw. als Grundlage des OpenAI-Modells;
10
+ - ``run.finalize(output, error_state)`` — Parsen -> Formprüfung über den Emitter
11
+ -> Fehler-Sperren -> Anhängen -> ``RunResult``.
12
+
13
+ Der Kern übergibt NIE selbst Prompts ans SDK und liest NIE SDK-Resultate: wo der
14
+ Baustein hingehört, wie strukturierte Ausgabe zurückkommt und wie Error/Abbruch
15
+ aussieht, ist Binding-Sache. Das Binding sammelt beides ein und ruft
16
+ ``finalize(...)``. Exception durch den ``with``-Block ODER ``error_state != None``
17
+ -> KEIN Attach. Der Kern bleibt SDK- und pydantic-frei; einziger Parse-Weg ist
18
+ ``json.loads`` auf der KOMPLETTEN Ausgabe (kein JSON-aus-Prosa-Extrahieren).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import functools
23
+ import json
24
+ import logging
25
+ from contextlib import contextmanager
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Iterator
28
+
29
+ from meander_agent.emitter import (
30
+ PLAN_VERSION,
31
+ ShapeError,
32
+ attach_plan,
33
+ validate_plan_shape,
34
+ )
35
+
36
+ logger = logging.getLogger("meander_agent.core")
37
+
38
+
39
+ # --------------------------------------------------------------------------- #
40
+ # Vokabular: nested Deklaration -> Prosa-Baustein + JSON-Schema
41
+ # --------------------------------------------------------------------------- #
42
+ def _normalize_vocabulary(vocabulary: Any) -> dict:
43
+ """Prüft die Vokabular-Form und gibt eine normalisierte Kopie zurück.
44
+
45
+ Erwartet::
46
+
47
+ {
48
+ "entities": {"<Entity>": {"identity": [...], "properties": [...]}},
49
+ "relations": {"<Relation>": {"from": "<Entity>", "to": "<Entity>"}},
50
+ "derivation_ids": [...],
51
+ }
52
+
53
+ Malformte Eingabe -> ValueError (laut, nie still).
54
+ """
55
+ if not isinstance(vocabulary, dict):
56
+ raise ValueError("vocabulary muss ein dict sein")
57
+ entities = vocabulary.get("entities", {})
58
+ relations = vocabulary.get("relations", {})
59
+ derivation_ids = vocabulary.get("derivation_ids", [])
60
+ if not isinstance(entities, dict):
61
+ raise ValueError("vocabulary['entities'] muss ein dict {Entity: {...}} sein")
62
+ if not isinstance(relations, dict):
63
+ raise ValueError("vocabulary['relations'] muss ein dict {Relation: {...}} sein")
64
+ if not isinstance(derivation_ids, list) or not all(isinstance(d, str) for d in derivation_ids):
65
+ raise ValueError("vocabulary['derivation_ids'] muss eine Liste von Strings sein")
66
+
67
+ norm_entities: dict[str, dict] = {}
68
+ for name, spec in entities.items():
69
+ if not isinstance(spec, dict):
70
+ raise ValueError(f"entities[{name!r}] muss ein dict {{identity, properties}} sein")
71
+ identity = spec.get("identity", [])
72
+ properties = spec.get("properties", [])
73
+ if not isinstance(identity, list) or not all(isinstance(f, str) for f in identity):
74
+ raise ValueError(f"entities[{name!r}]['identity'] muss eine Liste von Strings sein")
75
+ if not isinstance(properties, list) or not all(isinstance(p, str) for p in properties):
76
+ raise ValueError(f"entities[{name!r}]['properties'] muss eine Liste von Strings sein")
77
+ norm_entities[name] = {"identity": list(identity), "properties": list(properties)}
78
+
79
+ norm_relations: dict[str, dict] = {}
80
+ for name, spec in relations.items():
81
+ if not isinstance(spec, dict) or "from" not in spec or "to" not in spec:
82
+ raise ValueError(f"relations[{name!r}] muss ein dict {{from, to}} sein")
83
+ frm, to = spec["from"], spec["to"]
84
+ if frm not in norm_entities or to not in norm_entities:
85
+ raise ValueError(
86
+ f"relations[{name!r}] verweist auf nicht deklarierte Endpunkt-Entity "
87
+ f"(from={frm!r}, to={to!r})"
88
+ )
89
+ norm_relations[name] = {"from": frm, "to": to}
90
+
91
+ return {"entities": norm_entities, "relations": norm_relations,
92
+ "derivation_ids": list(derivation_ids)}
93
+
94
+
95
+ # Format-Baustein (Prompt-Hygiene: nur Platzhalter, keine echten Fakten-Werte).
96
+ _PLAN_FORMAT_PROMPT = """\
97
+ Liefere als FINALE strukturierte Ausgabe GENAU EIN JSON-Objekt im Plan-Format
98
+ (plan_version 1). Das Objekt hat diese Felder:
99
+
100
+ - "plan_version": muss exakt die Zahl 1 sein.
101
+ - "facts": Liste behaupteter Welt-Fakten. Jeder Fakt trägt SEINE Herkunft:
102
+ {"entity": "<entity>", "identity": {"<id_field>": "PLACEHOLDER_ID"},
103
+ "property": "<property>", "value": <typkonformer Wert>,
104
+ "origin": {"kind": "tool"|"api"|"human"|"agent", "ref": "<optional>"}}
105
+ Herkunft je Fakt: Tool-Ergebnisse origin.kind "tool", eigene Einschätzungen
106
+ origin.kind "agent". Fehlt "origin", gilt "agent".
107
+ - "relations": Liste behaupteter Beziehungen (optional):
108
+ {"relation": "<relation>",
109
+ "from": {"entity": "<entity>", "identity": {"<id_field>": "PLACEHOLDER_ID"}},
110
+ "to": {"entity": "<entity>", "identity": {"<id_field>": "PLACEHOLDER_ID"}},
111
+ "origin": {"kind": "tool"|"api"|"human"|"agent"}}
112
+ - "question": GENAU EINE Frage an eine bestehende Derivation:
113
+ {"derivation_id": "<derivation_id>",
114
+ "subject": {"entity": "<entity>", "identity": {"<id_field>": "PLACEHOLDER_ID"}}}
115
+ - "rule_proposals": optionale Liste von Regel-Vorschlägen:
116
+ {"id": "<proposal_id>", "head": {...}, "when": [...]}
117
+
118
+ Triff KEINE Entscheidung. Behaupte nur Fakten mit ihrer Herkunft und stelle die
119
+ eine Frage. Die PLACEHOLDER_ID- und <...>-Marker stehen für echte Werte aus
120
+ deinen Tool-Aufrufen, NICHT wörtlich zu übernehmen. Verwende AUSSCHLIESSLICH das
121
+ unten deklarierte Vokabular: je Entity nur ihre identity-Felder und Properties,
122
+ je Relation nur ihre deklarierten Endpunkt-Entities."""
123
+
124
+
125
+ def build_prompt_fragment(vocabulary: Any) -> str:
126
+ """Baustein: Format + das deklarierte Vokabular (task-frei).
127
+
128
+ Das Binding hängt die konkrete Aufgabe davor/als Nutzereingabe an. Nennt je
129
+ Entity ihre identity-Felder und Properties, je Relation ihre Endpunkte, sowie
130
+ die erlaubten Derivation-IDs — nur diese Namen sind zulässig.
131
+ """
132
+ v = _normalize_vocabulary(vocabulary)
133
+ lines = ["", "Erlaubtes Vokabular (nur diese Namen verwenden):", "Entities:"]
134
+ for name, spec in v["entities"].items():
135
+ ids = ", ".join(spec["identity"]) or "(keine)"
136
+ props = ", ".join(spec["properties"]) or "(keine)"
137
+ lines.append(f"- {name}: identity-Felder {{{ids}}}; properties {{{props}}}")
138
+ lines.append("Relations:")
139
+ if v["relations"]:
140
+ for name, spec in v["relations"].items():
141
+ lines.append(f"- {name}: from {spec['from']} to {spec['to']}")
142
+ else:
143
+ lines.append("- (keine)")
144
+ lines.append("Erlaubte Derivation-IDs: " + (", ".join(v["derivation_ids"]) or "(keine)"))
145
+ return f"{_PLAN_FORMAT_PROMPT}\n" + "\n".join(lines) + "\n"
146
+
147
+
148
+ _ORIGIN_SCHEMA = {
149
+ "type": "object",
150
+ "properties": {
151
+ "kind": {"type": "string", "enum": ["tool", "api", "human", "agent"]},
152
+ "ref": {"type": "string"},
153
+ },
154
+ "required": ["kind"],
155
+ "additionalProperties": False,
156
+ }
157
+
158
+
159
+ def _identity_schema(fields: list[str]) -> dict:
160
+ # Geschlossenes Objekt: identity-Schlüssel NUR aus den deklarierten Feldern
161
+ # (properties + additionalProperties:False; breit von Structured-Output unterstützt).
162
+ return {
163
+ "type": "object",
164
+ "properties": {f: {"type": "string"} for f in fields},
165
+ "additionalProperties": False,
166
+ }
167
+
168
+
169
+ def _endpoint_schema(entity_names: list[str], id_fields: list[str]) -> dict:
170
+ return {
171
+ "type": "object",
172
+ "properties": {
173
+ "entity": {"type": "string", "enum": entity_names},
174
+ "identity": _identity_schema(id_fields),
175
+ },
176
+ "required": ["entity", "identity"],
177
+ "additionalProperties": False,
178
+ }
179
+
180
+
181
+ def build_output_schema(vocabulary: Any) -> dict:
182
+ """JSON-Schema der strukturierten Ausgabe, an das Vokabular gebunden.
183
+
184
+ Erzwingt „nur deklarierte Namen" über Enums (entity/property/relation/
185
+ derivation_id) und geschlossene identity-Objekte (Schlüssel nur aus den
186
+ deklarierten id-Feldern). Die per-Relation-Paarung (welche Entity von/nach
187
+ welcher) und per-Entity-Property-Zuordnung stehen im ``prompt_fragment`` und
188
+ werden serverseitig autoritativ geprüft.
189
+ """
190
+ v = _normalize_vocabulary(vocabulary)
191
+ entity_names = list(v["entities"])
192
+ relation_names = list(v["relations"])
193
+ all_properties = sorted({p for e in v["entities"].values() for p in e["properties"]})
194
+ all_id_fields = sorted({f for e in v["entities"].values() for f in e["identity"]})
195
+
196
+ return {
197
+ "type": "object",
198
+ "properties": {
199
+ "plan_version": {"const": PLAN_VERSION},
200
+ "facts": {
201
+ "type": "array",
202
+ "items": {
203
+ "type": "object",
204
+ "properties": {
205
+ "entity": {"type": "string", "enum": entity_names},
206
+ "identity": _identity_schema(all_id_fields),
207
+ "property": {"type": "string", "enum": all_properties},
208
+ "value": {},
209
+ "origin": _ORIGIN_SCHEMA,
210
+ },
211
+ "required": ["entity", "identity", "property", "value"],
212
+ "additionalProperties": False,
213
+ },
214
+ },
215
+ "relations": {
216
+ "type": "array",
217
+ "items": {
218
+ "type": "object",
219
+ "properties": {
220
+ "relation": {"type": "string", "enum": relation_names},
221
+ "from": _endpoint_schema(entity_names, all_id_fields),
222
+ "to": _endpoint_schema(entity_names, all_id_fields),
223
+ "origin": _ORIGIN_SCHEMA,
224
+ },
225
+ "required": ["relation", "from", "to"],
226
+ "additionalProperties": False,
227
+ },
228
+ },
229
+ "question": {
230
+ "type": "object",
231
+ "properties": {
232
+ "derivation_id": {"type": "string", "enum": list(v["derivation_ids"])},
233
+ "subject": _endpoint_schema(entity_names, all_id_fields),
234
+ },
235
+ "required": ["derivation_id", "subject"],
236
+ "additionalProperties": False,
237
+ },
238
+ "rule_proposals": {
239
+ "type": "array",
240
+ "items": {
241
+ "type": "object",
242
+ "properties": {
243
+ "id": {"type": "string"},
244
+ "head": {"type": "object"},
245
+ "when": {"type": "array"},
246
+ },
247
+ "required": ["id", "head", "when"],
248
+ "additionalProperties": False,
249
+ },
250
+ },
251
+ },
252
+ "required": ["plan_version", "question"],
253
+ "additionalProperties": False,
254
+ }
255
+
256
+
257
+ # --------------------------------------------------------------------------- #
258
+ # RunResult + Kontextmanager + finalize
259
+ # --------------------------------------------------------------------------- #
260
+ @dataclass
261
+ class RunResult:
262
+ """Ergebnis eines Adapter-Laufs (auch bei Fehlern voll befüllt)."""
263
+
264
+ plan: dict | None = None
265
+ attached: bool = False
266
+ shape_errors: list[ShapeError] = field(default_factory=list)
267
+ error_state: Any = None
268
+
269
+
270
+ class Run:
271
+ """Vom ``meander_run``-Kontextmanager geliefertes Handle (nur SDK-neutrale Primitive)."""
272
+
273
+ def __init__(self, span: Any, vocabulary: Any) -> None:
274
+ self._span = span
275
+ self._vocabulary = vocabulary
276
+ self._prompt_fragment: str | None = None
277
+ self._output_schema: dict | None = None
278
+
279
+ @property
280
+ def prompt_fragment(self) -> str:
281
+ if self._prompt_fragment is None:
282
+ self._prompt_fragment = build_prompt_fragment(self._vocabulary)
283
+ return self._prompt_fragment
284
+
285
+ @property
286
+ def output_schema(self) -> dict:
287
+ if self._output_schema is None:
288
+ self._output_schema = build_output_schema(self._vocabulary)
289
+ return self._output_schema
290
+
291
+ def finalize(self, output: Any, error_state: Any) -> RunResult:
292
+ """Parsen -> Formprüfung -> Fehler-Sperren -> Anhängen -> RunResult.
293
+
294
+ - ``error_state is not None`` -> KEIN Attach, lautes Log (auch bei
295
+ gültiger ``output``: ein Fehlerlauf ist kein Plan).
296
+ - ``output`` ist ein dict -> direkt nutzen; ist ein String -> ``json.loads``
297
+ auf den GANZEN String (kein Prosa-Extrahieren); sonst kein Plan.
298
+ - Formfehler / fehlende Ausgabe / nicht beschreibbarer Span -> KEIN Attach.
299
+ """
300
+ if error_state is not None:
301
+ logger.error(
302
+ "Modell-Lauf meldet einen Error-State (%r) — output verworfen, "
303
+ "KEIN meander.plan-Attribut", error_state,
304
+ )
305
+ return RunResult(plan=None, attached=False, error_state=error_state)
306
+
307
+ plan: dict | None = output if isinstance(output, dict) else None
308
+ if plan is None and isinstance(output, str):
309
+ try:
310
+ parsed = json.loads(output)
311
+ except (TypeError, ValueError):
312
+ parsed = None
313
+ if isinstance(parsed, dict):
314
+ plan = parsed
315
+
316
+ if plan is None:
317
+ logger.error(
318
+ "kein Plan in der Modell-Ausgabe (output=%r) — KEIN meander.plan-Attribut",
319
+ output,
320
+ )
321
+ return RunResult(plan=None, attached=False, error_state=error_state)
322
+
323
+ shape_errors = validate_plan_shape(plan)
324
+ if shape_errors:
325
+ logger.error(
326
+ "Plan aus der Modell-Ausgabe ist formfehlerhaft — KEIN Attribut. Fehler: %s",
327
+ [e.as_dict() for e in shape_errors],
328
+ )
329
+ return RunResult(plan=plan, attached=False, shape_errors=shape_errors,
330
+ error_state=error_state)
331
+
332
+ attach = attach_plan(self._span, plan)
333
+ if not attach.ok:
334
+ # validate lief durch -> hier bleiben already_attached (Doppel-Anhang)
335
+ # oder span_not_recording (beendeter Span). Laut melden, nie still.
336
+ logger.error(
337
+ "attach_plan verweigert das Attribut: %s",
338
+ [e.as_dict() for e in attach.errors],
339
+ )
340
+ return RunResult(plan=plan, attached=False, shape_errors=attach.errors,
341
+ error_state=error_state)
342
+
343
+ return RunResult(plan=plan, attached=True, error_state=error_state)
344
+
345
+
346
+ @contextmanager
347
+ def meander_run(
348
+ *,
349
+ vocabulary: Any,
350
+ tracer: Any,
351
+ root_span_name: str = "agent.run",
352
+ ) -> Iterator[Run]:
353
+ """Öffnet den Run-Root-Span und liefert ein SDK-neutrales ``Run``-Handle.
354
+
355
+ Der Span bleibt offen, solange ``finalize`` läuft, und schließt beim Verlassen.
356
+ Eine Exception durch den ``with``-Block wird am Span vermerkt und lässt KEIN
357
+ Attribut zurück (``finalize`` lief dann nicht erfolgreich durch).
358
+ """
359
+ with tracer.start_as_current_span(root_span_name) as span:
360
+ yield Run(span=span, vocabulary=vocabulary)
361
+
362
+
363
+ def meander_run_decorator(
364
+ *,
365
+ vocabulary: Any,
366
+ tracer: Any,
367
+ root_span_name: str = "agent.run",
368
+ ):
369
+ """Dünner Zucker über ``meander_run``: dekoriert eine Funktion, die ``run``
370
+ als erstes Argument erhält. Kein Wrapping des Agent-Objekts."""
371
+ def deco(fn):
372
+ @functools.wraps(fn)
373
+ def wrapper(*args, **kwargs):
374
+ with meander_run(
375
+ vocabulary=vocabulary, tracer=tracer, root_span_name=root_span_name
376
+ ) as run:
377
+ return fn(run, *args, **kwargs)
378
+ return wrapper
379
+ return deco
@@ -0,0 +1,297 @@
1
+ # src/meander_agent/emitter.py
2
+ """SDK-neutraler Plan-Emitter: reine FORM-Prüfung + Attribut-Anhang.
3
+
4
+ Trägt das ``meander.plan``-Schema (Version 1) auf der SENDE-Seite. Prüft
5
+ AUSSCHLIESSLICH die FORM eines Plans (Pflichtfelder, Typen, genau eine Frage,
6
+ ``origin.kind``-Vokabular, ``plan_version == 1``) und hängt einen formgültigen
7
+ Plan als deterministisch serialisiertes Span-Attribut an einen echten OTel-Span.
8
+
9
+ EINE Wahrheit für Semantik: der Client prüft NIE gegen die Ontologie. Welche
10
+ Entities/Properties/Derivations existieren, weiß nur der Server (strikte
11
+ serverseitige Validierung im meander-Receiver). Dieses Modul importiert meander
12
+ NICHT und lädt keine Ontologie. Es hat auch KEINE Netzwerk-Logik — der Transport
13
+ bleibt beim OTel-Export.
14
+
15
+ Fehler werden nie still verschluckt: ``attach_plan`` gibt bei Formfehlern ein
16
+ ``AttachResult(ok=False, errors=[...])`` zurück und setzt KEIN Attribut (nie ein
17
+ leeres/Platzhalter-Objekt). Ein Doppel-Anhang auf denselben Span scheitert laut,
18
+ statt zu überschreiben.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+ from weakref import WeakSet
26
+
27
+ # --- Client-Konstanten (KEIN Import aus meander) ---------------------------- #
28
+ # Diese drei Konstanten spiegeln den Server-Kontrakt (meander.plans.semantics),
29
+ # leben hier aber als EIGENE Client-Konstanten an genau EINER Stelle. Der Wert
30
+ # von ``PLAN_VERSION`` wird gegen den Server-Wert abgeglichen (Gate-Test), aber
31
+ # nicht importiert — der Client bleibt von meander entkoppelt.
32
+ PLAN_ATTRIBUTE_KEY = "meander.plan"
33
+ ORIGIN_KINDS: tuple[str, ...] = ("tool", "api", "human", "agent")
34
+ PLAN_VERSION = 1
35
+
36
+ # Abschließende Schlüsselmengen (unbekannte Schlüssel -> unknown_key). Spiegelt
37
+ # die Server-Form (schema.py _TOP_KEYS etc.), damit ein Plan, den der Client
38
+ # durchlässt, formseitig auch am Server passiert.
39
+ _TOP_KEYS = {"plan_version", "facts", "relations", "question", "rule_proposals"}
40
+ _FACT_KEYS = {"entity", "identity", "property", "value", "origin"}
41
+ _RELATION_KEYS = {"relation", "from", "to", "origin"}
42
+ _ENDPOINT_KEYS = {"entity", "identity"}
43
+ _QUESTION_KEYS = {"derivation_id", "subject"}
44
+ _ORIGIN_KEYS = {"kind", "ref"}
45
+ _PROPOSAL_KEYS = {"id", "head", "when"}
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ShapeError:
50
+ """Ein FORM-Fehler mit Feldpfad (z.B. ``facts[2].origin.kind``), Code, Message."""
51
+
52
+ path: str
53
+ code: str
54
+ message: str
55
+
56
+ def as_dict(self) -> dict[str, str]:
57
+ return {"path": self.path, "code": self.code, "message": self.message}
58
+
59
+
60
+ @dataclass
61
+ class AttachResult:
62
+ """Ergebnis von ``attach_plan``: ok, gesammelte Fehler, serialisierter Plan."""
63
+
64
+ ok: bool
65
+ errors: list[ShapeError] = field(default_factory=list)
66
+ serialized: str | None = None
67
+
68
+
69
+ # Marker-Set: welche Spans schon ein Plan-Attribut per attach_plan tragen. Ein
70
+ # WeakSet hält keine Spans künstlich am Leben; Doppel-Anhang wird so erkannt,
71
+ # ohne den Span-Zustand zu lesen (manche Span-Impls geben Attribute nicht zurück).
72
+ _ATTACHED: "WeakSet[object]" = WeakSet()
73
+
74
+
75
+ class _Collector:
76
+ """Sammelt Fehler; bricht NIE beim ersten ab (alle Fehler auf einmal)."""
77
+
78
+ def __init__(self) -> None:
79
+ self.errors: list[ShapeError] = []
80
+
81
+ def add(self, path: str, code: str, message: str) -> None:
82
+ self.errors.append(ShapeError(path=path, code=code, message=message))
83
+
84
+ def unknown_keys(self, obj: dict[str, Any], allowed: set[str], path: str) -> None:
85
+ for key in obj:
86
+ if key not in allowed:
87
+ child = f"{path}.{key}" if path else str(key)
88
+ self.add(child, "unknown_key",
89
+ f"unbekannter Schlüssel {key!r}; erlaubt sind {sorted(allowed)}")
90
+
91
+
92
+ def validate_plan_shape(plan: Any) -> list[ShapeError]:
93
+ """Reine FORM-Prüfung eines Plans (Version 1). KEINE Ontologie-Prüfung.
94
+
95
+ Sammelt ALLE Fehler (kein early return) mit Feldpfad. Prüft:
96
+ ``plan_version`` exakt Integer 1; genau EINE ``question``; Pflichtfelder +
97
+ Typen je Fakt/Beziehung/Frage; ``origin.kind`` aus ORIGIN_KINDS (fehlendes
98
+ ``origin`` gilt als ``agent``, kein Fehler); ``rule_proposals`` Grobform;
99
+ unbekannte Schlüssel. ``facts``/``relations`` dürfen leer sein/fehlen.
100
+ """
101
+ col = _Collector()
102
+
103
+ if not isinstance(plan, dict):
104
+ col.add("", "invalid_shape",
105
+ f"Plan muss ein Objekt sein, ist {type(plan).__name__}")
106
+ return col.errors
107
+
108
+ col.unknown_keys(plan, _TOP_KEYS, "")
109
+
110
+ _check_version(plan.get("plan_version"), plan, col)
111
+ _check_facts(plan.get("facts"), plan, col)
112
+ _check_relations(plan.get("relations"), plan, col)
113
+ _check_question(plan, col)
114
+ _check_proposals(plan.get("rule_proposals"), plan, col)
115
+
116
+ return col.errors
117
+
118
+
119
+ def _check_version(version: Any, plan: dict, col: _Collector) -> None:
120
+ # Exakt Integer 1. bool ist eine int-Unterklasse (True == 1) -> ausschließen.
121
+ # String "1" ist ein Fehler (gegen den Server-Wert PLAN_VERSION abgeglichen).
122
+ if "plan_version" not in plan:
123
+ col.add("plan_version", "invalid_plan_version",
124
+ f"plan_version muss exakt {PLAN_VERSION} sein, fehlt aber")
125
+ return
126
+ if not isinstance(version, int) or isinstance(version, bool) or version != PLAN_VERSION:
127
+ col.add("plan_version", "invalid_plan_version",
128
+ f"plan_version muss exakt {PLAN_VERSION} (Integer) sein, bekam {version!r}")
129
+
130
+
131
+ def _check_origin(raw: Any, path: str, col: _Collector) -> None:
132
+ """Fehlendes ``origin`` gilt als {kind: agent} — KEIN Fehler."""
133
+ if raw is None:
134
+ return
135
+ if not isinstance(raw, dict):
136
+ col.add(path, "invalid_shape", "origin muss ein Objekt {kind, ref?} sein")
137
+ return
138
+ col.unknown_keys(raw, _ORIGIN_KEYS, path)
139
+ kind = raw.get("kind")
140
+ if kind not in ORIGIN_KINDS:
141
+ col.add(f"{path}.kind", "unknown_origin_kind",
142
+ f"origin.kind muss aus {list(ORIGIN_KINDS)} sein, bekam {kind!r}")
143
+ ref = raw.get("ref")
144
+ if ref is not None and not isinstance(ref, str):
145
+ col.add(f"{path}.ref", "invalid_shape", "origin.ref muss ein String sein")
146
+
147
+
148
+ def _check_identity(raw: Any, path: str, col: _Collector) -> None:
149
+ """Identity muss ein Objekt {feld: wert} sein (FORM; keine Deckungs-Prüfung)."""
150
+ if not isinstance(raw, dict):
151
+ col.add(path, "invalid_shape", "identity muss ein Objekt {feld: wert} sein")
152
+
153
+
154
+ def _check_facts(raw: Any, plan: dict, col: _Collector) -> None:
155
+ if raw is None:
156
+ return # facts dürfen fehlen
157
+ if not isinstance(raw, list):
158
+ col.add("facts", "invalid_shape", "facts muss eine Liste sein")
159
+ return
160
+ for i, item in enumerate(raw):
161
+ path = f"facts[{i}]"
162
+ if not isinstance(item, dict):
163
+ col.add(path, "invalid_shape", "jeder Fakt muss ein Objekt sein")
164
+ continue
165
+ col.unknown_keys(item, _FACT_KEYS, path)
166
+ if not isinstance(item.get("entity"), str) or not item.get("entity"):
167
+ col.add(f"{path}.entity", "invalid_shape", "entity muss ein nicht-leerer String sein")
168
+ _check_identity(item.get("identity"), f"{path}.identity", col)
169
+ if not isinstance(item.get("property"), str) or not item.get("property"):
170
+ col.add(f"{path}.property", "invalid_shape", "property muss ein nicht-leerer String sein")
171
+ if "value" not in item:
172
+ col.add(f"{path}.value", "missing_value", "Fakt braucht einen 'value'")
173
+ _check_origin(item.get("origin"), f"{path}.origin", col)
174
+
175
+
176
+ def _check_relations(raw: Any, plan: dict, col: _Collector) -> None:
177
+ if raw is None:
178
+ return # relations dürfen fehlen
179
+ if not isinstance(raw, list):
180
+ col.add("relations", "invalid_shape", "relations muss eine Liste sein")
181
+ return
182
+ for i, item in enumerate(raw):
183
+ path = f"relations[{i}]"
184
+ if not isinstance(item, dict):
185
+ col.add(path, "invalid_shape", "jede Beziehung muss ein Objekt sein")
186
+ continue
187
+ col.unknown_keys(item, _RELATION_KEYS, path)
188
+ if not isinstance(item.get("relation"), str) or not item.get("relation"):
189
+ col.add(f"{path}.relation", "invalid_shape", "relation muss ein nicht-leerer String sein")
190
+ _check_endpoint(item.get("from"), f"{path}.from", col)
191
+ _check_endpoint(item.get("to"), f"{path}.to", col)
192
+ _check_origin(item.get("origin"), f"{path}.origin", col)
193
+
194
+
195
+ def _check_endpoint(raw: Any, path: str, col: _Collector) -> None:
196
+ """Ein Beziehungs-Endpunkt braucht {entity, identity} (FORM)."""
197
+ if not isinstance(raw, dict):
198
+ col.add(path, "invalid_shape", "braucht ein Objekt {entity, identity}")
199
+ return
200
+ col.unknown_keys(raw, _ENDPOINT_KEYS, path)
201
+ if not isinstance(raw.get("entity"), str) or not raw.get("entity"):
202
+ col.add(f"{path}.entity", "invalid_shape", "entity muss ein nicht-leerer String sein")
203
+ _check_identity(raw.get("identity"), f"{path}.identity", col)
204
+
205
+
206
+ def _check_question(plan: dict, col: _Collector) -> None:
207
+ """Genau EINE Frage: fehlt -> missing_question; Liste -> multiple_questions."""
208
+ if "question" not in plan:
209
+ col.add("question", "missing_question", "der Plan braucht genau eine Frage")
210
+ return
211
+ raw = plan["question"]
212
+ if isinstance(raw, list):
213
+ col.add("question", "multiple_questions", "genau EINE Frage — question ist keine Liste")
214
+ return
215
+ if not isinstance(raw, dict):
216
+ col.add("question", "invalid_shape", "question muss ein Objekt sein")
217
+ return
218
+ col.unknown_keys(raw, _QUESTION_KEYS, "question")
219
+ if not isinstance(raw.get("derivation_id"), str) or not raw.get("derivation_id"):
220
+ col.add("question.derivation_id", "invalid_shape",
221
+ "derivation_id muss ein nicht-leerer String sein")
222
+ subject = raw.get("subject")
223
+ if not isinstance(subject, dict):
224
+ col.add("question.subject", "invalid_shape", "subject braucht ein Objekt {entity, identity}")
225
+ return
226
+ col.unknown_keys(subject, _ENDPOINT_KEYS, "question.subject")
227
+ if not isinstance(subject.get("entity"), str) or not subject.get("entity"):
228
+ col.add("question.subject.entity", "invalid_shape",
229
+ "entity muss ein nicht-leerer String sein")
230
+ _check_identity(subject.get("identity"), "question.subject.identity", col)
231
+
232
+
233
+ def _check_proposals(raw: Any, plan: dict, col: _Collector) -> None:
234
+ if raw is None:
235
+ return # rule_proposals optional
236
+ if not isinstance(raw, list):
237
+ col.add("rule_proposals", "invalid_shape", "rule_proposals muss eine Liste sein")
238
+ return
239
+ for i, item in enumerate(raw):
240
+ path = f"rule_proposals[{i}]"
241
+ if not isinstance(item, dict):
242
+ col.add(path, "invalid_shape", "jeder Vorschlag muss ein Objekt sein")
243
+ continue
244
+ col.unknown_keys(item, _PROPOSAL_KEYS, path)
245
+ # Grobform: id str, head dict, when list. Die Regel-Grammatik selbst prüft
246
+ # der Server (build_rule_expr) — der Client kennt sie nicht.
247
+ if not isinstance(item.get("id"), str) or not item.get("id"):
248
+ col.add(f"{path}.id", "invalid_shape", "id muss ein nicht-leerer String sein")
249
+ if not isinstance(item.get("head"), dict):
250
+ col.add(f"{path}.head", "invalid_shape", "head muss ein Objekt sein")
251
+ if not isinstance(item.get("when"), list):
252
+ col.add(f"{path}.when", "invalid_shape", "when muss eine Liste sein")
253
+
254
+
255
+ def attach_plan(span: Any, plan: Any) -> AttachResult:
256
+ """Prüft die FORM und hängt einen gültigen Plan als Span-Attribut an.
257
+
258
+ (1) ``validate_plan_shape``; bei Fehlern KEIN Attribut, ``ok=False``.
259
+ (2) Erfolg: deterministische Serialisierung
260
+ (``json.dumps(plan, ensure_ascii=False, sort_keys=True)``) und
261
+ ``span.set_attribute(PLAN_ATTRIBUTE_KEY, serialized)``.
262
+ (3) Doppel-Anhang auf DEMSELBEN Span scheitert (``already_attached``), das
263
+ vorhandene Attribut bleibt unverändert (kein Überschreiben).
264
+ (4) Ein bereits beendeter Span (``is_recording()`` false) scheitert laut
265
+ (``span_not_recording``) — OpenTelemetry würde das Attribut sonst still
266
+ verschlucken.
267
+ """
268
+ if span in _ATTACHED:
269
+ return AttachResult(
270
+ ok=False,
271
+ errors=[ShapeError(
272
+ path="", code="already_attached",
273
+ message="an diesem Span hängt bereits ein meander.plan-Attribut; "
274
+ "kein Überschreiben",
275
+ )],
276
+ )
277
+
278
+ # Beendete Spans nehmen keine Attribute mehr an (OTel setzt sie still nicht).
279
+ # hasattr-abgesichert, falls ein Span-artiges Objekt kein is_recording() hat.
280
+ is_recording = getattr(span, "is_recording", None)
281
+ if callable(is_recording) and not is_recording():
282
+ return AttachResult(
283
+ ok=False,
284
+ errors=[ShapeError(
285
+ path="", code="span_not_recording",
286
+ message="Span ist nicht mehr beschreibbar",
287
+ )],
288
+ )
289
+
290
+ errors = validate_plan_shape(plan)
291
+ if errors:
292
+ return AttachResult(ok=False, errors=errors)
293
+
294
+ serialized = json.dumps(plan, ensure_ascii=False, sort_keys=True)
295
+ span.set_attribute(PLAN_ATTRIBUTE_KEY, serialized)
296
+ _ATTACHED.add(span)
297
+ return AttachResult(ok=True, serialized=serialized)
@@ -0,0 +1,175 @@
1
+ # src/meander_agent/openai.py
2
+ """Dünnes OpenAI-Agents-SDK-Binding auf den Kern (``meander_run`` / ``finalize``).
3
+
4
+ SDK-spezifisch ist hier NUR: das ``PlanModel`` (Pydantic-``output_type`` für die
5
+ strukturierte finale Ausgabe), der ``Runner.run``-Aufruf (Run-Ende), das Auslesen
6
+ der strukturierten Ausgabe (``final_output_as``) und die Übersetzung von
7
+ ``AgentsException`` auf den neutralen ``error_state``. Baustein, Parsen,
8
+ Formprüfung, Fehler-Sperren, Anhängen und ``RunResult`` kommen ALLE aus dem Kern.
9
+
10
+ Am echten Install (openai-agents 0.17) verifiziert:
11
+ - ``Runner.run(agent, input)`` / ``Runner.run_sync(...)`` -> ``RunResult`` nur bei
12
+ Completion, sonst ``agents.exceptions.AgentsException`` (Basis) geworfen.
13
+ - strukturierte Ausgabe via ``Agent(output_type=...)`` +
14
+ ``result.final_output_as(PlanModel, raise_if_incorrect_type=True)``.
15
+ - Das Plan-Schema (``value: Any``, ``dict``-identity, optionale Felder) ist NICHT
16
+ strict-kompatibel -> ``output_type = AgentOutputSchema(PlanModel,
17
+ strict_json_schema=False)`` (sonst UserError am SDK).
18
+
19
+ Kein meander-Import. Der Root-Span wird vom Kern gehalten; das Binding ruft
20
+ ``run.finalize(output, error_state)``, solange er offen ist.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Literal, Optional
25
+
26
+ from pydantic import BaseModel, ConfigDict, Field
27
+
28
+ from meander_agent.core import RunResult, meander_run
29
+
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # PlanModel: Pydantic-output_type für OpenAIs strukturierte finale Ausgabe.
33
+ # Spiegelt das Plan-Format (Schema v1). model_dump(by_alias=True) ergibt einen
34
+ # plan-dict, den der Emitter FORM-prüft ('from' braucht ein Alias).
35
+ # --------------------------------------------------------------------------- #
36
+ class _Origin(BaseModel):
37
+ model_config = ConfigDict(extra="forbid")
38
+ kind: str
39
+ ref: Optional[str] = None
40
+
41
+
42
+ class _Endpoint(BaseModel):
43
+ model_config = ConfigDict(extra="forbid")
44
+ entity: str
45
+ identity: dict[str, str] = Field(default_factory=dict)
46
+
47
+
48
+ class _Fact(BaseModel):
49
+ model_config = ConfigDict(extra="forbid")
50
+ entity: str
51
+ identity: dict[str, str] = Field(default_factory=dict)
52
+ property: str
53
+ value: Any = None
54
+ origin: Optional[_Origin] = None
55
+
56
+
57
+ class _Relation(BaseModel):
58
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
59
+ relation: str
60
+ from_: _Endpoint = Field(alias="from")
61
+ to: _Endpoint
62
+ origin: Optional[_Origin] = None
63
+
64
+
65
+ class _Question(BaseModel):
66
+ model_config = ConfigDict(extra="forbid")
67
+ derivation_id: str
68
+ subject: _Endpoint
69
+
70
+
71
+ class _RuleProposal(BaseModel):
72
+ model_config = ConfigDict(extra="forbid")
73
+ id: str
74
+ head: dict[str, Any] = Field(default_factory=dict)
75
+ when: list[Any] = Field(default_factory=list)
76
+
77
+
78
+ class PlanModel(BaseModel):
79
+ """Pydantic-Abbild des Plan-Formats (plan_version 1) für ``output_type``."""
80
+
81
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
82
+ plan_version: Literal[1] = 1
83
+ facts: list[_Fact] = Field(default_factory=list)
84
+ relations: list[_Relation] = Field(default_factory=list)
85
+ question: _Question
86
+ rule_proposals: list[_RuleProposal] = Field(default_factory=list)
87
+
88
+
89
+ def plan_output_type(plan_model: type[BaseModel] = PlanModel):
90
+ """Der non-strict ``output_type`` fürs Agent-Objekt (Plan-Schema ist nicht strict)."""
91
+ from agents import AgentOutputSchema
92
+
93
+ return AgentOutputSchema(plan_model, strict_json_schema=False)
94
+
95
+
96
+ def _plan_dict(final_output: BaseModel) -> dict:
97
+ """PlanModel-Instanz -> plan-dict (by_alias, damit 'from'/'to' korrekt heißen)."""
98
+ return final_output.model_dump(by_alias=True)
99
+
100
+
101
+ def _openai_error_state(exc: BaseException) -> dict:
102
+ """AgentsException (Fehler/Abbruch) -> neutraler error_state (Sperre im Kern)."""
103
+ return {"exception": type(exc).__name__, "detail": str(exc)}
104
+
105
+
106
+ async def run_with_plan_async(
107
+ task: str,
108
+ *,
109
+ vocabulary: Any,
110
+ tracer: Any,
111
+ root_span_name: str = "agent.run",
112
+ plan_model: type[BaseModel] = PlanModel,
113
+ agent_name: str = "plan-agent",
114
+ ) -> RunResult:
115
+ """Fährt Runner.run mit output_type=PlanModel und finalisiert über den Kern.
116
+
117
+ Erfolg -> ``final_output_as(plan_model).model_dump()`` -> ``run.finalize``.
118
+ Jede ``AgentsException`` (Fehler/Abbruch) -> ``error_state`` -> KEIN Attach.
119
+ """
120
+ from agents import Agent, Runner
121
+ from agents.exceptions import AgentsException
122
+
123
+ with meander_run(vocabulary=vocabulary, tracer=tracer, root_span_name=root_span_name) as run:
124
+ agent = Agent(
125
+ name=agent_name,
126
+ instructions=run.prompt_fragment,
127
+ output_type=plan_output_type(plan_model),
128
+ )
129
+ try:
130
+ result = await Runner.run(agent, task)
131
+ except AgentsException as exc:
132
+ return run.finalize(None, error_state=_openai_error_state(exc))
133
+ try:
134
+ final = result.final_output_as(plan_model, raise_if_incorrect_type=True)
135
+ except TypeError as exc:
136
+ # final_output_as wirft ein reines TypeError (KEIN AgentsException),
137
+ # wenn die finale Ausgabe nicht der PlanModel-Typ ist (Tool-als-Final-
138
+ # Output-/Handoff-Pfade). Auch das ist ein Fehlerausgang -> Sperre über
139
+ # den Kern, nie ein Roh-Crash beim Aufrufer.
140
+ return run.finalize(None, error_state=_openai_error_state(exc))
141
+ return run.finalize(_plan_dict(final), error_state=None)
142
+
143
+
144
+ def run_with_plan(
145
+ task: str,
146
+ *,
147
+ vocabulary: Any,
148
+ tracer: Any,
149
+ root_span_name: str = "agent.run",
150
+ plan_model: type[BaseModel] = PlanModel,
151
+ agent_name: str = "plan-agent",
152
+ ) -> RunResult:
153
+ """Synchroner Weg über ``Runner.run_sync`` (deterministische Nutzung)."""
154
+ from agents import Agent, Runner
155
+ from agents.exceptions import AgentsException
156
+
157
+ with meander_run(vocabulary=vocabulary, tracer=tracer, root_span_name=root_span_name) as run:
158
+ agent = Agent(
159
+ name=agent_name,
160
+ instructions=run.prompt_fragment,
161
+ output_type=plan_output_type(plan_model),
162
+ )
163
+ try:
164
+ result = Runner.run_sync(agent, task)
165
+ except AgentsException as exc:
166
+ return run.finalize(None, error_state=_openai_error_state(exc))
167
+ try:
168
+ final = result.final_output_as(plan_model, raise_if_incorrect_type=True)
169
+ except TypeError as exc:
170
+ # final_output_as wirft ein reines TypeError (KEIN AgentsException),
171
+ # wenn die finale Ausgabe nicht der PlanModel-Typ ist (Tool-als-Final-
172
+ # Output-/Handoff-Pfade). Auch das ist ein Fehlerausgang -> Sperre über
173
+ # den Kern, nie ein Roh-Crash beim Aufrufer.
174
+ return run.finalize(None, error_state=_openai_error_state(exc))
175
+ return run.finalize(_plan_dict(final), error_state=None)
@@ -0,0 +1,85 @@
1
+ # src/meander_agent/transport.py
2
+ """Transport-Setup: OTel-Tracing + OTLP/HTTP-Export auf den meander-Ingress.
3
+
4
+ ``init_meander(endpoint, source_key)`` ist für Kunden ohne eigenes OTel der
5
+ einzige nötige Aufruf: er baut einen EIGENEN TracerProvider samt OTLP/HTTP-
6
+ Exporter auf den meander-Ingress und gibt einen ``MeanderClient`` zurück. Kein
7
+ stilles globales ``set_tracer_provider`` — der Client hält seinen eigenen
8
+ Provider. Kunden mit bestehendem Tracing nutzen stattdessen ``meander_run(...,
9
+ tracer=ihr_tracer)`` direkt; das Paket erzwingt keinen Provider.
10
+
11
+ Der Run-Root-Span wird nicht hier, sondern über den Kern-Kontextmanager geöffnet
12
+ (``client.run(vocabulary=...)`` / ``meander_run``, Block B).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
17
+ from opentelemetry.sdk.resources import Resource
18
+ from opentelemetry.sdk.trace import TracerProvider
19
+
20
+
21
+ class MeanderClient:
22
+ """Transport-Griff: hält den eigenen Provider/Tracer für den meander-Export."""
23
+
24
+ def __init__(self, tracer, provider: TracerProvider) -> None:
25
+ self._tracer = tracer
26
+ self._provider = provider
27
+
28
+ @property
29
+ def tracer(self):
30
+ """Der auf den meander-Ingress verdrahtete Tracer (für ``meander_run``)."""
31
+ return self._tracer
32
+
33
+ def run(self, *, vocabulary, root_span_name: str = "agent.run"):
34
+ """Zucker: ``meander_run`` an den eigenen Tracer gebunden.
35
+
36
+ ``with client.run(vocabulary=...) as run:`` öffnet den Run-Root-Span über
37
+ den Kern-Kontextmanager auf dem verdrahteten Tracer.
38
+ """
39
+ from meander_agent.core import meander_run
40
+
41
+ return meander_run(
42
+ vocabulary=vocabulary, tracer=self._tracer, root_span_name=root_span_name
43
+ )
44
+
45
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
46
+ """Exportiert anstehende Spans sofort (nützlich für kurze Skripte/Tests)."""
47
+ return self._provider.force_flush(timeout_millis)
48
+
49
+ def shutdown(self) -> None:
50
+ """Fährt den eigenen Provider herunter (flusht dabei den Exporter)."""
51
+ self._provider.shutdown()
52
+
53
+
54
+ def init_meander(
55
+ endpoint: str,
56
+ source_key: str,
57
+ *,
58
+ service_name: str = "meander-agent",
59
+ ) -> MeanderClient:
60
+ """Richtet Tracing + OTLP/HTTP-Export auf den meander-Ingress ein.
61
+
62
+ ``endpoint`` = die VOLLE OTLP-Traces-URL
63
+ (``…/api/sources/<source_id>/v1/traces``); ``source_key`` = Bearer-Token
64
+ dieser Source. KEIN URL-Building hier. Leerer ``endpoint``/``source_key`` ->
65
+ ``ValueError`` (laut, nie still). KEIN globales Provider-Setzen.
66
+ """
67
+ if not endpoint or not endpoint.strip():
68
+ raise ValueError(
69
+ "init_meander braucht eine nicht-leere OTLP-Traces-URL (endpoint), "
70
+ "z.B. https://<host>/api/sources/<source_id>/v1/traces"
71
+ )
72
+ if not source_key or not source_key.strip():
73
+ raise ValueError("init_meander braucht einen nicht-leeren source_key (Bearer-Token)")
74
+
75
+ provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
76
+ exporter = OTLPSpanExporter(
77
+ endpoint=endpoint,
78
+ headers={"authorization": f"Bearer {source_key}"},
79
+ )
80
+ # BatchSpanProcessor lazily importiert, damit der Import-Pfad schlank bleibt.
81
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
82
+
83
+ provider.add_span_processor(BatchSpanProcessor(exporter))
84
+ tracer = provider.get_tracer("meander-agent")
85
+ return MeanderClient(tracer=tracer, provider=provider)
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: meander-agent
3
+ Version: 0.1.0
4
+ Summary: Client-side meander.plan SDK: transport setup + a form-checked plan onto a real OTel span, with thin Claude/OpenAI bindings. Imports meander NOT.
5
+ Author-email: Raphael Feikert <r.feikert@symbolic-intelligence.de>
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.30
8
+ Requires-Dist: opentelemetry-sdk<2,>=1.30
9
+ Provides-Extra: claude
10
+ Requires-Dist: claude-agent-sdk<0.3,>=0.2; extra == 'claude'
11
+ Provides-Extra: openai
12
+ Requires-Dist: openai-agents<0.18,>=0.17; extra == 'openai'
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest-randomly>=3; extra == 'test'
15
+ Requires-Dist: pytest>=8; extra == 'test'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # meander-agent
19
+
20
+ Kundenseitiges SDK für die **Plan-Sende-Seite** von meander. Dein Agent hält
21
+ fest, was er getan hat, als nachvollziehbare Behauptung mit Herkunft, und stellt
22
+ GENAU EINE Frage an eine Regel deiner Ontologie. Das Paket baut daraus ein
23
+ `meander.plan`, prüft dessen FORM und hängt es an einen echten OpenTelemetry-Span,
24
+ den dein OTLP-Export zu meander trägt. Kein meander-Import, keine Ontologie im
25
+ Client.
26
+
27
+ ## Das Modell in einem Absatz
28
+
29
+ Ein `meander.plan` ist eine **Behauptung plus eine Frage**, keine Entscheidung:
30
+
31
+ - **facts** — was der Agent beobachtet/eingeschätzt hat. Jeder Fakt trägt SEINE
32
+ Herkunft (`origin.kind`: `tool` | `api` | `human` | `agent`). Ein Tool-Ergebnis
33
+ ist `tool`, die eigene Einschätzung des Agenten ist `agent`.
34
+ - **relations** — behauptete Beziehungen zwischen Entities (optional).
35
+ - **question** — GENAU EINE Frage an eine bestehende Derivation (Regel) deiner
36
+ Welt, z.B. „gilt hier `requires_review`?". Der Agent **entscheidet nicht**; ob
37
+ die Regel greift, bestimmt meander serverseitig, und ein Mensch prüft.
38
+
39
+ Der Client prüft nur die **FORM** (Pflichtfelder, Typen, genau eine Frage,
40
+ `origin`-Vokabular, `plan_version == 1`). Ob die Entities/Properties/Derivations
41
+ wirklich existieren, weiß nur der Server (er kennt deine Ontologie). Darum
42
+ importiert dieses Paket meander NICHT und lädt keine Ontologie.
43
+
44
+ **Garantien (nie stilles Nichtstun):** ein Fehlerlauf hängt NIE ein Attribut an;
45
+ ein nicht mehr beschreibbarer Span verweigert das Attribut laut
46
+ (`AttachResult(ok=False, span_not_recording)`); ein formfehlerhafter Plan wird mit
47
+ Feldpfad gemeldet, statt halb geschrieben zu werden.
48
+
49
+ ## Installation
50
+
51
+ ```
52
+ pip install meander-agent # Transport + Emitter (schlank, ohne LLM-SDK)
53
+ pip install "meander-agent[claude]" # + Claude-Agent-SDK-Binding
54
+ pip install "meander-agent[openai]" # + OpenAI-Agents-SDK-Binding
55
+ ```
56
+
57
+ ## 1. Dein Vokabular deklarieren
58
+
59
+ Du gibst dem Agenten die Namen deiner Welt (er soll nichts erfinden). Je Entity
60
+ ihre identity-Felder und Properties, je Relation ihre Endpunkte, plus die
61
+ Derivation-IDs, die er befragen darf:
62
+
63
+ ```python
64
+ vocabulary = {
65
+ "entities": {
66
+ "Order": {"identity": ["order_id"], "properties": ["amount", "risk"]},
67
+ "Case": {"identity": ["case_id"], "properties": []},
68
+ "Outcome": {"identity": ["outcome_id"], "properties": []},
69
+ },
70
+ "relations": {
71
+ "concerns": {"from": "Case", "to": "Order"},
72
+ },
73
+ "derivation_ids": ["requires_review.high_value"],
74
+ }
75
+ ```
76
+
77
+ Daraus erzeugt das Paket den Prompt-Baustein (nennt dem Modell nur diese Namen)
78
+ und das JSON-Schema der strukturierten Ausgabe (erlaubt nur diese Namen).
79
+
80
+ ## 2. Transport einrichten
81
+
82
+ Ohne eigenes OTel genügt ein Aufruf. `endpoint` ist die volle OTLP-Traces-URL
83
+ deiner Source, `source_key` das Bearer-Token dieser Source:
84
+
85
+ ```python
86
+ from meander_agent import init_meander
87
+
88
+ client = init_meander(
89
+ endpoint="https://<host>/api/sources/<source_id>/v1/traces",
90
+ source_key="<bearer-token>",
91
+ )
92
+ # client.tracer -> der verdrahtete OTel-Tracer
93
+ # client.shutdown() / client.force_flush() -> Export abschließen
94
+ ```
95
+
96
+ `init_meander` setzt KEINEN globalen Provider; der Client hält seinen eigenen.
97
+ Hast du bereits ein OTel-Setup, lass `init_meander` weg und übergib deinen Tracer
98
+ direkt (siehe Abschnitt 5).
99
+
100
+ ## 3. Einen Agenten-Run fahren (Claude)
101
+
102
+ ```python
103
+ from meander_agent.claude import run_with_plan
104
+
105
+ result = run_with_plan(
106
+ "Bearbeite Bestellung ORD-42. Rufe die üblichen Tools auf, behaupte die "
107
+ "gewonnenen Fakten mit ihrer Herkunft und stelle GENAU EINE Frage an die "
108
+ "Derivation requires_review.high_value. Triff KEINE Entscheidung.",
109
+ vocabulary=vocabulary,
110
+ tracer=client.tracer,
111
+ )
112
+ print("angehängt" if result.attached else f"kein Plan gesetzt: {result.error_state}")
113
+ client.shutdown() # Span exportieren
114
+ ```
115
+
116
+ Das Binding öffnet den Root-Span, fährt das Modell mit strukturierter Ausgabe,
117
+ sperrt bei Fehler/Abbruch und hängt bei Erfolg den geprüften Plan an. Es gibt ein
118
+ `RunResult` zurück (siehe Abschnitt 4). Braucht das `[claude]`-Extra und einen
119
+ `ANTHROPIC_API_KEY`.
120
+
121
+ ## 3b. Derselbe Run über OpenAI
122
+
123
+ Identischer Aufruf, anderes Binding (Extra `[openai]`, `OPENAI_API_KEY`):
124
+
125
+ ```python
126
+ from meander_agent.openai import run_with_plan
127
+
128
+ result = run_with_plan(task, vocabulary=vocabulary, tracer=client.tracer)
129
+ ```
130
+
131
+ Beide Bindings gibt es auch async (`run_with_plan_async`).
132
+
133
+ ## 4. Was du zurückbekommst
134
+
135
+ `RunResult`:
136
+
137
+ - `attached: bool` — wurde `meander.plan` an den Span gehängt?
138
+ - `plan: dict | None` — der angehängte Plan (bei Erfolg).
139
+ - `shape_errors: list[ShapeError]` — Formfehler mit `path` / `code` / `message`,
140
+ falls die Ausgabe die FORM-Prüfung nicht bestand.
141
+ - `error_state` — `None` bei Erfolg; sonst der Fehlerausgang des Laufs
142
+ (SDK-Fehler, Abbruch, Typ-Mismatch). Ist er gesetzt, wird NIE angehängt.
143
+
144
+ Kein `meander.plan` heißt also immer: entweder ein Fehlerlauf (`error_state`) oder
145
+ eine formfehlerhafte Ausgabe (`shape_errors`) — beides steht im Ergebnis, nichts
146
+ verschwindet still.
147
+
148
+ ## 5. Eigenes Tracing / eigenes SDK (der Kern)
149
+
150
+ Willst du dein eigenes SDK anbinden (oder deinen eigenen Tracer nutzen), fährst du
151
+ den SDK-neutralen Kontextmanager selbst. Er liefert dir den Baustein und das
152
+ Schema und übernimmt Parsen, Formprüfung, Sperren und Anhängen:
153
+
154
+ ```python
155
+ from meander_agent import meander_run
156
+
157
+ with meander_run(vocabulary=vocabulary, tracer=my_tracer) as run:
158
+ # run.prompt_fragment : Format + erlaubtes Vokabular -> als Instruktion ans SDK
159
+ # run.output_schema : JSON-Schema, falls dein SDK strukturierte Ausgabe kann
160
+ output, error = call_your_llm(task, instructions=run.prompt_fragment,
161
+ schema=run.output_schema)
162
+ # output: das strukturierte Ergebnis (dict) ODER ein reiner JSON-String.
163
+ # error_state: None bei Erfolg, sonst ein beliebiges Detail (=> Sperre).
164
+ result = run.finalize(output, error_state=error)
165
+ ```
166
+
167
+ Der Kern übergibt nie selbst Prompts ans SDK und liest nie SDK-Resultate — das ist
168
+ Sache deines Bindings. Genauso arbeiten die mitgelieferten Claude-/OpenAI-Bindings.
169
+
170
+ ## 6. Deterministischer Plan ohne LLM
171
+
172
+ Baust du den Plan selbst (Tests, regelbasierte Agenten, auth-loser Pfad), nutzt du
173
+ den Emitter direkt:
174
+
175
+ ```python
176
+ from meander_agent import attach_plan, validate_plan_shape
177
+
178
+ plan = {
179
+ "plan_version": 1,
180
+ "facts": [
181
+ {"entity": "Order", "identity": {"order_id": "ORD-42"},
182
+ "property": "amount", "value": 900,
183
+ "origin": {"kind": "tool", "ref": "lookup_order"}},
184
+ ],
185
+ "relations": [
186
+ {"relation": "concerns",
187
+ "from": {"entity": "Case", "identity": {"case_id": "C-1"}},
188
+ "to": {"entity": "Order", "identity": {"order_id": "ORD-42"}},
189
+ "origin": {"kind": "agent"}},
190
+ ],
191
+ "question": {
192
+ "derivation_id": "requires_review.high_value",
193
+ "subject": {"entity": "Case", "identity": {"case_id": "C-1"}},
194
+ },
195
+ }
196
+
197
+ errors = validate_plan_shape(plan) # reine FORM-Prüfung (leer = ok)
198
+ with client.tracer.start_as_current_span("agent.run") as span:
199
+ res = attach_plan(span, plan) # hängt bei Formgültigkeit an
200
+ if not res.ok:
201
+ print("nicht angehängt:", [e.as_dict() for e in res.errors])
202
+ client.shutdown()
203
+ ```
204
+
205
+ ## Öffentliche Fläche
206
+
207
+ | Symbol | Zweck |
208
+ |---|---|
209
+ | `init_meander(endpoint, source_key) -> MeanderClient` | Transport (OTel + OTLP) einrichten |
210
+ | `MeanderClient.tracer / .run(vocabulary=…) / .force_flush() / .shutdown()` | Tracer, Kern-Zucker, Export |
211
+ | `meander_agent.claude.run_with_plan[_async](task, *, vocabulary, tracer)` | Claude-Binding |
212
+ | `meander_agent.openai.run_with_plan[_async](task, *, vocabulary, tracer)` | OpenAI-Binding |
213
+ | `meander_run(vocabulary, tracer) -> Run` | SDK-neutraler Kern-Kontextmanager |
214
+ | `Run.prompt_fragment / .output_schema / .finalize(output, error_state)` | Bausteine + Verarbeitung |
215
+ | `attach_plan(span, plan) -> AttachResult` | FORM-Prüfung + Anhang |
216
+ | `validate_plan_shape(plan) -> list[ShapeError]` | reine FORM-Prüfung |
217
+ | `RunResult`, `AttachResult`, `ShapeError` | Ergebnis-/Fehlertypen |
218
+ | `PLAN_ATTRIBUTE_KEY`, `ORIGIN_KINDS`, `PLAN_VERSION` | Client-Konstanten |
219
+
220
+ ## Entwicklung
221
+
222
+ ```
223
+ pixi run test # zufällige Reihenfolge (pytest-randomly)
224
+ pixi run -- pytest -p no:randomly # feste Reihenfolge
225
+ ```
226
+
227
+ Die credential-gated Live-LLM-Tests (Claude/OpenAI) laufen dort, wo die Keys
228
+ gesetzt sind, und werden sonst übersprungen; die deterministische Suite läuft
229
+ immer und ohne Mocks.
@@ -0,0 +1,9 @@
1
+ meander_agent/__init__.py,sha256=FwT-xAOXwAOR5mgSD_2la7ywHFScO_bdtRMwFUpLptc,1350
2
+ meander_agent/claude.py,sha256=KbiDA6oBdbw4Vr2pSKUG1Tsd63zRo3FvAM_aP10Cme4,3987
3
+ meander_agent/core.py,sha256=Y2iFvGukMmjmKTMkG3m3IZcNqzuvfV8mZyZhFuCnhKw,16023
4
+ meander_agent/emitter.py,sha256=15VRDrsbO7btNP5Yrgr7MuirwQHHRhy4yIhkoIfvIpk,13310
5
+ meander_agent/openai.py,sha256=cnMjurSvpyCtfMfgyYw17AQBhyL5e6udFqNcSKlSxN8,6865
6
+ meander_agent/transport.py,sha256=VlO0I-iTSQ50H70oJNmlB3gC2ntrsayzvW52IS4yynE,3542
7
+ meander_agent-0.1.0.dist-info/METADATA,sha256=W9ddljb4Lm0qq0BMxuzMaQjZW9U4OunktAJCBqnS5wA,9324
8
+ meander_agent-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ meander_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any