omagent 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.
omagent/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """omagent — testable tooling layer for LLM-assisted OpenModelica workflows."""
2
+
3
+ from .errors import (
4
+ Diagnostic, Kind, Severity,
5
+ classify, parse_error_string, parse_ompython_exception, parse_simulation_messages, summarize_for_llm,
6
+ )
7
+ from .session import Backend, OMSession, OpResult
8
+ from .loop import AgentLoop, Attempt, LLM, LoopResult, Verifier, extract_code, extract_model_name
9
+ from .results import (SimulationResult, all_of, expect_bounds, expect_final,
10
+ expect_value_at, load_result)
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = [
14
+ "Diagnostic", "Kind", "Severity", "classify", "parse_error_string",
15
+ "parse_ompython_exception", "parse_simulation_messages", "summarize_for_llm",
16
+ "Backend", "OMSession", "OpResult",
17
+ "AgentLoop", "Attempt", "LLM", "LoopResult", "Verifier",
18
+ "extract_code", "extract_model_name",
19
+ "SimulationResult", "load_result", "expect_final", "expect_value_at",
20
+ "expect_bounds", "all_of",
21
+ ]
omagent/errors.py ADDED
@@ -0,0 +1,259 @@
1
+ """Parser for OpenModelica compiler (omc) diagnostics.
2
+
3
+ Turns raw getErrorString()/simulate() output into structured records that an
4
+ LLM agent (or a human) can act on. This module is deliberately free of any
5
+ OMPython or network dependency so it can be unit-tested anywhere.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field, asdict
12
+ from enum import Enum
13
+ from typing import Optional
14
+
15
+
16
+ class Severity(str, Enum):
17
+ ERROR = "error"
18
+ WARNING = "warning"
19
+ NOTIFICATION = "notification"
20
+
21
+
22
+ class Kind(str, Enum):
23
+ """Coarse classification of what went wrong — used to steer fix strategies."""
24
+
25
+ SYNTAX = "syntax" # parse errors, missing tokens
26
+ LOOKUP = "lookup" # class/variable not found
27
+ TYPE = "type" # type mismatches, unit issues
28
+ BALANCE = "balance" # equation/variable count mismatch, singular systems
29
+ CONNECT = "connect" # connector/connection problems
30
+ INITIALIZATION = "initialization"
31
+ RUNTIME = "runtime" # simulation-time failures (solver, events, asserts)
32
+ OTHER = "other"
33
+
34
+
35
+ # [/path/File.mo:12:3-14:20:writable] Error: message...
36
+ _LOCATED = re.compile(
37
+ r"^\[(?P<file>[^\]]*?):(?P<l1>\d+):(?P<c1>\d+)-(?P<l2>\d+):(?P<c2>\d+):[^\]]*\]\s*"
38
+ r"(?P<sev>Error|Warning|Notification):\s*(?P<msg>.*)$",
39
+ re.DOTALL,
40
+ )
41
+
42
+ # Error: message... (no source location)
43
+ _BARE = re.compile(
44
+ r"^(?P<sev>Error|Warning|Notification):\s*(?P<msg>.*)$", re.DOTALL
45
+ )
46
+
47
+ _SEV_MAP = {
48
+ "Error": Severity.ERROR,
49
+ "Warning": Severity.WARNING,
50
+ "Notification": Severity.NOTIFICATION,
51
+ }
52
+
53
+ # Ordered: first match wins.
54
+ _KIND_RULES: list[tuple[Kind, re.Pattern]] = [
55
+ (Kind.SYNTAX, re.compile(
56
+ r"Parse error|Parser error|Missing token|syntax error|unexpected token|"
57
+ r"Expected token", re.IGNORECASE)),
58
+ (Kind.LOOKUP, re.compile(
59
+ r"not found in scope|Class \S+ not found|Variable \S+ not found|"
60
+ r"Base class \S+ not found|component .* not found", re.IGNORECASE)),
61
+ (Kind.TYPE, re.compile(
62
+ r"Type mismatch|expected subtype|incompatible types|"
63
+ r"unit .* not compatible|Illegal type", re.IGNORECASE)),
64
+ (Kind.CONNECT, re.compile(
65
+ r"connector|connect\(|not a valid connect|flow variable|stream variable",
66
+ re.IGNORECASE)),
67
+ (Kind.BALANCE, re.compile(
68
+ r"imbalanced|structurally singular|too (?:many|few) equations|"
69
+ r"under-?determined|over-?determined|"
70
+ r"equations? \(\d+\).*variables? \(\d+\)|"
71
+ r"\d+ equations? and \d+ variables?", re.IGNORECASE)),
72
+ (Kind.INITIALIZATION, re.compile(
73
+ r"initial(?:ization| equation| conditions?)|start value|fixed=", re.IGNORECASE)),
74
+ (Kind.RUNTIME, re.compile(
75
+ r"Simulation execution failed|solver|integrator|assert\b|division by zero|"
76
+ r"nonlinear system|chattering|stopped at time|LOG_", re.IGNORECASE)),
77
+ ]
78
+
79
+
80
+ @dataclass
81
+ class Diagnostic:
82
+ severity: Severity
83
+ message: str
84
+ kind: Kind = Kind.OTHER
85
+ file: Optional[str] = None
86
+ line_start: Optional[int] = None
87
+ col_start: Optional[int] = None
88
+ line_end: Optional[int] = None
89
+ col_end: Optional[int] = None
90
+
91
+ def to_dict(self) -> dict:
92
+ d = asdict(self)
93
+ d["severity"] = self.severity.value
94
+ d["kind"] = self.kind.value
95
+ return d
96
+
97
+ def brief(self) -> str:
98
+ loc = ""
99
+ if self.file and self.line_start is not None:
100
+ loc = f"{self.file}:{self.line_start}: "
101
+ return f"[{self.severity.value}/{self.kind.value}] {loc}{self.message}"
102
+
103
+
104
+ def classify(message: str) -> Kind:
105
+ for kind, pat in _KIND_RULES:
106
+ if pat.search(message):
107
+ return kind
108
+ return Kind.OTHER
109
+
110
+
111
+ def _split_records(raw: str) -> list[str]:
112
+ """Split a getErrorString() blob into individual diagnostic records.
113
+
114
+ omc concatenates records as lines; a record starts with '[' (located) or a
115
+ severity keyword, and continuation lines belong to the previous record.
116
+ """
117
+ records: list[str] = []
118
+ current: list[str] = []
119
+ start = re.compile(r"^(\[|Error:|Warning:|Notification:)")
120
+ for line in raw.splitlines():
121
+ if start.match(line.strip()) and current:
122
+ records.append("\n".join(current).strip())
123
+ current = [line]
124
+ elif start.match(line.strip()):
125
+ current = [line]
126
+ elif current:
127
+ current.append(line)
128
+ elif line.strip():
129
+ # Leading unstructured text (e.g. simulation stdout) — keep as record.
130
+ current = [line]
131
+ if current:
132
+ records.append("\n".join(current).strip())
133
+ return [r for r in records if r.strip().strip('"')]
134
+
135
+
136
+ def parse_error_string(raw: str) -> list[Diagnostic]:
137
+ """Parse the output of omc's getErrorString() into Diagnostics."""
138
+ if not raw:
139
+ return []
140
+ raw = raw.strip().strip('"').strip()
141
+ if not raw:
142
+ return []
143
+
144
+ out: list[Diagnostic] = []
145
+ for rec in _split_records(raw):
146
+ m = _LOCATED.match(rec)
147
+ if m:
148
+ msg = m.group("msg").strip()
149
+ out.append(Diagnostic(
150
+ severity=_SEV_MAP[m.group("sev")],
151
+ message=msg,
152
+ kind=classify(msg),
153
+ file=m.group("file") or None,
154
+ line_start=int(m.group("l1")),
155
+ col_start=int(m.group("c1")),
156
+ line_end=int(m.group("l2")),
157
+ col_end=int(m.group("c2")),
158
+ ))
159
+ continue
160
+ m = _BARE.match(rec)
161
+ if m:
162
+ msg = m.group("msg").strip()
163
+ out.append(Diagnostic(
164
+ severity=_SEV_MAP[m.group("sev")],
165
+ message=msg,
166
+ kind=classify(msg),
167
+ ))
168
+ continue
169
+ # Unstructured content (runtime stdout etc.)
170
+ out.append(Diagnostic(
171
+ severity=Severity.ERROR if "fail" in rec.lower() else Severity.NOTIFICATION,
172
+ message=rec.strip(),
173
+ kind=classify(rec),
174
+ ))
175
+ return out
176
+
177
+
178
+ def parse_simulation_messages(messages: str) -> list[Diagnostic]:
179
+ """Parse the 'messages' field of an omc simulate() result record.
180
+
181
+ Runtime logs look like: LOG_STDOUT | error | msg or plain text lines.
182
+ """
183
+ if not messages:
184
+ return []
185
+ out: list[Diagnostic] = []
186
+ log_line = re.compile(
187
+ r"^\s*(?P<stream>LOG_\w+)\s*\|\s*(?P<level>\w+)\s*\|\s*(?P<msg>.*)$")
188
+ for line in messages.splitlines():
189
+ if not line.strip():
190
+ continue
191
+ m = log_line.match(line)
192
+ if m:
193
+ level = m.group("level").lower()
194
+ sev = (Severity.ERROR if level in ("error", "assert")
195
+ else Severity.WARNING if level == "warning"
196
+ else Severity.NOTIFICATION)
197
+ msg = m.group("msg").strip()
198
+ out.append(Diagnostic(severity=sev, message=msg,
199
+ kind=classify(msg) if classify(msg) != Kind.OTHER
200
+ else Kind.RUNTIME))
201
+ else:
202
+ sev = (Severity.ERROR if re.search(r"fail|error", line, re.IGNORECASE)
203
+ else Severity.NOTIFICATION)
204
+ out.append(Diagnostic(severity=sev, message=line.strip(),
205
+ kind=classify(line)))
206
+ return out
207
+
208
+
209
+ def summarize_for_llm(diags: list[Diagnostic], limit: int = 20) -> str:
210
+ """Compact, deduplicated plain-text summary suitable for an LLM fix prompt."""
211
+ errors = [d for d in diags if d.severity == Severity.ERROR]
212
+ warnings = [d for d in diags if d.severity == Severity.WARNING]
213
+ seen: set[str] = set()
214
+ lines: list[str] = []
215
+ for d in errors + warnings:
216
+ b = d.brief()
217
+ if b not in seen:
218
+ seen.add(b)
219
+ lines.append(b)
220
+ if len(lines) >= limit:
221
+ lines.append(f"... ({len(errors) + len(warnings) - limit} more suppressed)")
222
+ break
223
+ return "\n".join(lines) if lines else "No errors or warnings."
224
+
225
+
226
+ # Newer OMPython raises OMCSessionException whose message embeds omc's log as
227
+ # "[OMC log for 'sendExpression(...)']: [kind:level:id] message"
228
+ _OMPY_EXC = re.compile(
229
+ r"\[(?P<kind>\w+):(?P<level>error|warning|notification):(?P<id>-?\d+)\]\s*"
230
+ r"(?P<msg>.*)", re.DOTALL)
231
+
232
+ _OMPY_KIND_MAP = {
233
+ "syntax": Kind.SYNTAX,
234
+ "grammar": Kind.SYNTAX,
235
+ "simulation": Kind.RUNTIME,
236
+ }
237
+
238
+
239
+ def parse_ompython_exception(text: str) -> list[Diagnostic]:
240
+ """Parse an OMPython OMCSessionException message into Diagnostics.
241
+
242
+ Falls back to a single generic error diagnostic for unstructured messages
243
+ (e.g. ZeroMQ connection failures), so backend exceptions never vanish.
244
+ """
245
+ m = _OMPY_EXC.search(text or "")
246
+ if not m:
247
+ return [Diagnostic(Severity.ERROR, (text or "unknown backend failure").strip(),
248
+ Kind.OTHER)]
249
+ level = m.group("level")
250
+ sev = (Severity.ERROR if level == "error"
251
+ else Severity.WARNING if level == "warning"
252
+ else Severity.NOTIFICATION)
253
+ msg = m.group("msg").strip()
254
+ # message-based classification wins when specific; else fall back to
255
+ # omc's own kind tag (syntax/grammar/simulation/...)
256
+ kind = classify(msg)
257
+ if kind == Kind.OTHER:
258
+ kind = _OMPY_KIND_MAP.get(m.group("kind").lower(), Kind.OTHER)
259
+ return [Diagnostic(sev, msg, kind)]
omagent/llm.py ADDED
@@ -0,0 +1,96 @@
1
+ """LLM adapters implementing the ``omagent.loop.LLM`` protocol.
2
+
3
+ Currently ships an Anthropic adapter (``ClaudeLLM``). Any other provider can
4
+ be used by implementing the one-method ``propose`` protocol; nothing in the
5
+ loop depends on this module.
6
+
7
+ Install: ``pip install omagent[llm]`` and set ``ANTHROPIC_API_KEY``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional
13
+
14
+ SYSTEM_PROMPT = (
15
+ "You are an expert Modelica engineer producing models for OpenModelica.\n"
16
+ "Rules:\n"
17
+ "- Return ONE complete, self-contained Modelica model, and nothing else: "
18
+ "no prose before or after. A ```modelica fence is acceptable.\n"
19
+ "- The model must simulate in plain OpenModelica. Prefer the Modelica "
20
+ "Standard Library; never invent classes.\n"
21
+ "- Give every state a start value with fixed=true unless the task says "
22
+ "otherwise, so initialization is deterministic.\n"
23
+ "- Use SI units and add unit attributes where natural.\n"
24
+ "- When shown a failed attempt and its errors, return the corrected FULL "
25
+ "model, not a diff or fragment."
26
+ )
27
+
28
+ _FRESH_TEMPLATE = """Task: write a Modelica model for the following.
29
+
30
+ {task}
31
+
32
+ Return the complete model."""
33
+
34
+ _FIX_TEMPLATE = """Task: {task}
35
+
36
+ The model below failed. Fix it and return the complete corrected model.
37
+
38
+ Failed model:
39
+ ```modelica
40
+ {code}
41
+ ```
42
+
43
+ Diagnostics:
44
+ {errors}"""
45
+
46
+
47
+ class ClaudeLLM:
48
+ """Anthropic-backed implementation of the LLM protocol.
49
+
50
+ Records every round in ``self.transcript`` — these (task, code, errors,
51
+ response) tuples are exactly the material worth keeping as benchmark
52
+ seeds and paper data.
53
+ """
54
+
55
+ def __init__(self, model: str = "claude-sonnet-4-6", max_tokens: int = 3000,
56
+ client: Optional[Any] = None, _force_import_error: bool = False):
57
+ if client is None or _force_import_error:
58
+ try:
59
+ if _force_import_error:
60
+ raise ImportError
61
+ import anthropic
62
+ client = anthropic.Anthropic()
63
+ except ImportError as exc:
64
+ raise ImportError(
65
+ "ClaudeLLM requires the anthropic package: "
66
+ "pip install omagent[llm]") from exc
67
+ self.client = client
68
+ self.model = model
69
+ self.max_tokens = max_tokens
70
+ self.transcript: list[dict] = []
71
+
72
+ def propose(self, task: str, previous_code: Optional[str],
73
+ error_summary: Optional[str]) -> str:
74
+ if previous_code is None:
75
+ user = _FRESH_TEMPLATE.format(task=task)
76
+ else:
77
+ user = _FIX_TEMPLATE.format(
78
+ task=task, code=previous_code,
79
+ errors=error_summary or "(no diagnostics captured)")
80
+
81
+ msg = self.client.messages.create(
82
+ model=self.model,
83
+ max_tokens=self.max_tokens,
84
+ system=SYSTEM_PROMPT,
85
+ messages=[{"role": "user", "content": user}],
86
+ )
87
+ text = "\n".join(
88
+ block.text for block in msg.content
89
+ if getattr(block, "type", "") == "text")
90
+ self.transcript.append({
91
+ "task": task,
92
+ "previous_code": previous_code,
93
+ "error_summary": error_summary,
94
+ "response": text,
95
+ })
96
+ return text
omagent/loop.py ADDED
@@ -0,0 +1,188 @@
1
+ """Agentic generate -> load -> check -> simulate -> verify loop.
2
+
3
+ The LLM is behind a narrow protocol so the loop is unit-testable with a fake
4
+ and backend-agnostic in production (Anthropic, OpenAI, local models — anything
5
+ that can implement `propose`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable, Optional, Protocol
13
+
14
+ import difflib
15
+
16
+ from .errors import Diagnostic, Kind, summarize_for_llm
17
+ from .session import OMSession, OpResult
18
+
19
+
20
+ class LLM(Protocol):
21
+ def propose(
22
+ self,
23
+ task: str,
24
+ previous_code: Optional[str],
25
+ error_summary: Optional[str],
26
+ ) -> str:
27
+ """Return complete Modelica code for `task`.
28
+
29
+ First call: previous_code/error_summary are None (fresh generation).
30
+ Later calls: both are set (repair a failed attempt).
31
+ """
32
+ ...
33
+
34
+
35
+ # A verifier inspects the simulate() OpResult; returns None if satisfied,
36
+ # else a human/LLM-readable complaint that is fed into the next fix round.
37
+ Verifier = Callable[[OpResult], Optional[str]]
38
+
39
+ _MODEL_NAME = re.compile(r"^\s*(?:model|block|package)\s+([A-Za-z_][A-Za-z0-9_]*)",
40
+ re.MULTILINE)
41
+
42
+ _CODE_FENCE = re.compile(r"```(?:modelica|mo)?\s*\n(.*?)```", re.DOTALL)
43
+
44
+
45
+ def extract_code(llm_output: str) -> str:
46
+ """Accept raw Modelica or a fenced markdown block; return bare code."""
47
+ m = _CODE_FENCE.search(llm_output)
48
+ return (m.group(1) if m else llm_output).strip()
49
+
50
+
51
+ def extract_model_name(code: str) -> Optional[str]:
52
+ m = _MODEL_NAME.search(code)
53
+ return m.group(1) if m else None
54
+
55
+
56
+ @dataclass
57
+ class Attempt:
58
+ n: int
59
+ code: str
60
+ stage: str # "load" | "check" | "simulate" | "verify" | "ok"
61
+ diagnostics: list[Diagnostic] = field(default_factory=list)
62
+ complaint: Optional[str] = None # verifier feedback, if any
63
+
64
+ @property
65
+ def failed(self) -> bool:
66
+ return self.stage != "ok"
67
+
68
+
69
+ @dataclass
70
+ class LoopResult:
71
+ success: bool
72
+ attempts: list[Attempt]
73
+ model_name: Optional[str] = None
74
+ sim_result: Optional[OpResult] = None
75
+
76
+ @property
77
+ def final_code(self) -> Optional[str]:
78
+ return self.attempts[-1].code if self.attempts else None
79
+
80
+
81
+ class AgentLoop:
82
+ def __init__(
83
+ self,
84
+ session: OMSession,
85
+ llm: LLM,
86
+ max_attempts: int = 4,
87
+ simulate_options: Optional[dict] = None,
88
+ verifier: Optional[Verifier] = None,
89
+ ):
90
+ if max_attempts < 1:
91
+ raise ValueError("max_attempts must be >= 1")
92
+ self.session = session
93
+ self.llm = llm
94
+ self.max_attempts = max_attempts
95
+ self.simulate_options = simulate_options or {}
96
+ self.verifier = verifier
97
+
98
+ def run(self, task: str, model_name: Optional[str] = None) -> LoopResult:
99
+ attempts: list[Attempt] = []
100
+ prev_code: Optional[str] = None
101
+ feedback: Optional[str] = None
102
+
103
+ for n in range(1, self.max_attempts + 1):
104
+ code = extract_code(self.llm.propose(task, prev_code, feedback))
105
+ name = model_name or extract_model_name(code)
106
+ if not name:
107
+ att = Attempt(n, code, "load")
108
+ att.complaint = ("Could not determine the model name: the code "
109
+ "must contain a top-level `model <Name>`.")
110
+ attempts.append(att)
111
+ prev_code, feedback = code, att.complaint
112
+ continue
113
+
114
+ att, sim = self._try_once(n, code, name)
115
+ attempts.append(att)
116
+ if not att.failed:
117
+ return LoopResult(True, attempts, name, sim)
118
+ prev_code = code
119
+ feedback = self._feedback(att)
120
+
121
+ return LoopResult(False, attempts, model_name or
122
+ extract_model_name(attempts[-1].code))
123
+
124
+ # -- internals --------------------------------------------------------
125
+ def _try_once(self, n: int, code: str, name: str):
126
+ res = self.session.load_string(code)
127
+ if not res.success:
128
+ return Attempt(n, code, "load", res.diagnostics), None
129
+
130
+ res = self.session.check_model(name)
131
+ if not res.success:
132
+ return Attempt(n, code, "check", res.diagnostics), None
133
+
134
+ sim = self.session.simulate(name, **self.simulate_options)
135
+ if not sim.success:
136
+ return Attempt(n, code, "simulate", sim.diagnostics), None
137
+
138
+ if self.verifier is not None:
139
+ complaint = self.verifier(sim)
140
+ if complaint:
141
+ return Attempt(n, code, "verify", sim.diagnostics, complaint), sim
142
+
143
+ return Attempt(n, code, "ok", sim.diagnostics), sim
144
+
145
+ def _feedback(self, att: Attempt) -> str:
146
+ parts = [f"Attempt failed at stage '{att.stage}'."]
147
+ if att.diagnostics:
148
+ parts.append(summarize_for_llm(att.diagnostics))
149
+ parts.extend(self._lookup_suggestions(att.diagnostics))
150
+ if att.complaint:
151
+ parts.append(f"Verification feedback: {att.complaint}")
152
+ return "\n".join(parts)
153
+
154
+ _MISSING_CLASS = re.compile(r"(?:Class|Import)\s+([A-Za-z_][\w.]*\.[\w]+)"
155
+ r"\s+not found")
156
+
157
+ def _lookup_suggestions(self, diags: list[Diagnostic],
158
+ max_packages: int = 3) -> list[str]:
159
+ """For lookup failures, ask omc what the parent package really
160
+ contains and surface close matches — turns 'X not found' into
161
+ 'did you mean RotationalEMF' (e.g. MSL 3.2 -> 4.x renames)."""
162
+ out: list[str] = []
163
+ seen: set[str] = set()
164
+ for d in diags:
165
+ if d.kind != Kind.LOOKUP:
166
+ continue
167
+ m = self._MISSING_CLASS.search(d.message)
168
+ if not m:
169
+ continue
170
+ fqn = m.group(1)
171
+ parent, _, leaf = fqn.rpartition(".")
172
+ if not parent or parent in seen:
173
+ continue
174
+ seen.add(parent)
175
+ names = self.session.class_names(parent)
176
+ if not names:
177
+ continue
178
+ close = difflib.get_close_matches(leaf, names, n=5, cutoff=0.4)
179
+ sub = [n for n in names
180
+ if leaf.lower() in n.lower() and n not in close]
181
+ picks = (close + sub)[:6] or sorted(names)[:12]
182
+ out.append(
183
+ f"Hint: {fqn} does not exist in the loaded libraries. "
184
+ f"{parent} actually contains: {', '.join(picks)}. "
185
+ f"Use one of these exact names.")
186
+ if len(seen) >= max_packages:
187
+ break
188
+ return out