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 +21 -0
- omagent/errors.py +259 -0
- omagent/llm.py +96 -0
- omagent/loop.py +188 -0
- omagent/results.py +266 -0
- omagent/runner.py +100 -0
- omagent/session.py +147 -0
- omagent/tasks.py +148 -0
- omagent-0.1.0.dist-info/METADATA +213 -0
- omagent-0.1.0.dist-info/RECORD +13 -0
- omagent-0.1.0.dist-info/WHEEL +5 -0
- omagent-0.1.0.dist-info/licenses/LICENSE +29 -0
- omagent-0.1.0.dist-info/top_level.txt +1 -0
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
|