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.
- meander_agent/__init__.py +47 -0
- meander_agent/claude.py +101 -0
- meander_agent/core.py +379 -0
- meander_agent/emitter.py +297 -0
- meander_agent/openai.py +175 -0
- meander_agent/transport.py +85 -0
- meander_agent-0.1.0.dist-info/METADATA +229 -0
- meander_agent-0.1.0.dist-info/RECORD +9 -0
- meander_agent-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
]
|
meander_agent/claude.py
ADDED
|
@@ -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
|
meander_agent/emitter.py
ADDED
|
@@ -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)
|
meander_agent/openai.py
ADDED
|
@@ -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,,
|