cfdpilot 0.1.11__tar.gz
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.
- cfdpilot-0.1.11/.gitignore +8 -0
- cfdpilot-0.1.11/PKG-INFO +57 -0
- cfdpilot-0.1.11/README.md +42 -0
- cfdpilot-0.1.11/cfdpilot/__init__.py +0 -0
- cfdpilot-0.1.11/cfdpilot/agent.py +331 -0
- cfdpilot-0.1.11/cfdpilot/auth.py +57 -0
- cfdpilot-0.1.11/cfdpilot/case_detector.py +104 -0
- cfdpilot-0.1.11/cfdpilot/client.py +65 -0
- cfdpilot-0.1.11/cfdpilot/display.py +106 -0
- cfdpilot-0.1.11/cfdpilot/main.py +65 -0
- cfdpilot-0.1.11/cfdpilot/tools/__init__.py +0 -0
- cfdpilot-0.1.11/cfdpilot/tools/filesystem.py +45 -0
- cfdpilot-0.1.11/cfdpilot/tools/log_parser.py +94 -0
- cfdpilot-0.1.11/cfdpilot/tools/patcher.py +42 -0
- cfdpilot-0.1.11/cfdpilot/tools/runner.py +159 -0
- cfdpilot-0.1.11/pyproject.toml +25 -0
- cfdpilot-0.1.11/tests/fixtures/fvSolution_sample +32 -0
- cfdpilot-0.1.11/tests/fixtures/log_pimplefoam_diverging.txt +26 -0
- cfdpilot-0.1.11/tests/fixtures/log_simplefoam_converged.txt +21 -0
- cfdpilot-0.1.11/tests/test_auth.py +13 -0
- cfdpilot-0.1.11/tests/test_case_detector.py +29 -0
- cfdpilot-0.1.11/tests/test_filesystem.py +26 -0
- cfdpilot-0.1.11/tests/test_integration.py +85 -0
- cfdpilot-0.1.11/tests/test_log_parser.py +31 -0
- cfdpilot-0.1.11/tests/test_patcher.py +32 -0
- cfdpilot-0.1.11/tests/test_runner.py +16 -0
cfdpilot-0.1.11/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cfdpilot
|
|
3
|
+
Version: 0.1.11
|
|
4
|
+
Summary: AI agent for OpenFOAM — diagnose, fix, and relaunch cases from your terminal
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: python-dotenv>=1.0
|
|
8
|
+
Requires-Dist: rich>=13
|
|
9
|
+
Requires-Dist: typer>=0.12
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# CFDpilot
|
|
17
|
+
|
|
18
|
+
**An AI agent for OpenFOAM — in your terminal.**
|
|
19
|
+
|
|
20
|
+
CFDpilot reads your OpenFOAM case directly from disk, diagnoses why it diverges or
|
|
21
|
+
gives wrong physics, proposes the exact fix as a colored diff, and — once you confirm —
|
|
22
|
+
patches the files and reruns the solver. No ZIP upload, no browser. It runs where you
|
|
23
|
+
already work: your laptop, your workstation, or an HPC node over SSH.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install cfdpilot
|
|
27
|
+
cfdpilot login # authenticate once
|
|
28
|
+
cd path/to/your/case && cfdpilot
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then just ask, in plain English:
|
|
32
|
+
|
|
33
|
+
> *"Why won't my rhoSimpleFoam case converge?"*
|
|
34
|
+
|
|
35
|
+
The agent maps your case, reads `fvSchemes`, `fvSolution`, your boundary conditions and
|
|
36
|
+
solver log, reasons from the actual numbers (Courant number, residuals, turbulence wall
|
|
37
|
+
treatment, scheme/switch consistency), and reports the bugs ranked by impact — with the
|
|
38
|
+
exact fix for each. Nothing is changed without showing you a diff first, and every patch
|
|
39
|
+
makes a `.bak` backup.
|
|
40
|
+
|
|
41
|
+
## What it checks
|
|
42
|
+
|
|
43
|
+
Boundary-condition consistency, turbulence wall treatment (low-Re vs high-Re), Courant
|
|
44
|
+
number and time-step control, relaxation factors, discretisation schemes, solver-mode
|
|
45
|
+
switches (e.g. `transonic`), and solver/turbulence-model compatibility — across
|
|
46
|
+
incompressible, compressible, multiphase (VoF) and heat-transfer solvers.
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- Python 3.10+
|
|
51
|
+
- An OpenFOAM case directory (Foundation or ESI). OpenFOAM itself can run natively or in
|
|
52
|
+
Docker; the agent will use it to run the solver when you ask.
|
|
53
|
+
|
|
54
|
+
## Access
|
|
55
|
+
|
|
56
|
+
CFDpilot is in **free early access** for the first engineers. Run `cfdpilot login` to get
|
|
57
|
+
started, or request access at [cfdpilot.com](https://cfdpilot.com).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# CFDpilot
|
|
2
|
+
|
|
3
|
+
**An AI agent for OpenFOAM — in your terminal.**
|
|
4
|
+
|
|
5
|
+
CFDpilot reads your OpenFOAM case directly from disk, diagnoses why it diverges or
|
|
6
|
+
gives wrong physics, proposes the exact fix as a colored diff, and — once you confirm —
|
|
7
|
+
patches the files and reruns the solver. No ZIP upload, no browser. It runs where you
|
|
8
|
+
already work: your laptop, your workstation, or an HPC node over SSH.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install cfdpilot
|
|
12
|
+
cfdpilot login # authenticate once
|
|
13
|
+
cd path/to/your/case && cfdpilot
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then just ask, in plain English:
|
|
17
|
+
|
|
18
|
+
> *"Why won't my rhoSimpleFoam case converge?"*
|
|
19
|
+
|
|
20
|
+
The agent maps your case, reads `fvSchemes`, `fvSolution`, your boundary conditions and
|
|
21
|
+
solver log, reasons from the actual numbers (Courant number, residuals, turbulence wall
|
|
22
|
+
treatment, scheme/switch consistency), and reports the bugs ranked by impact — with the
|
|
23
|
+
exact fix for each. Nothing is changed without showing you a diff first, and every patch
|
|
24
|
+
makes a `.bak` backup.
|
|
25
|
+
|
|
26
|
+
## What it checks
|
|
27
|
+
|
|
28
|
+
Boundary-condition consistency, turbulence wall treatment (low-Re vs high-Re), Courant
|
|
29
|
+
number and time-step control, relaxation factors, discretisation schemes, solver-mode
|
|
30
|
+
switches (e.g. `transonic`), and solver/turbulence-model compatibility — across
|
|
31
|
+
incompressible, compressible, multiphase (VoF) and heat-transfer solvers.
|
|
32
|
+
|
|
33
|
+
## Requirements
|
|
34
|
+
|
|
35
|
+
- Python 3.10+
|
|
36
|
+
- An OpenFOAM case directory (Foundation or ESI). OpenFOAM itself can run natively or in
|
|
37
|
+
Docker; the agent will use it to run the solver when you ask.
|
|
38
|
+
|
|
39
|
+
## Access
|
|
40
|
+
|
|
41
|
+
CFDpilot is in **free early access** for the first engineers. Run `cfdpilot login` to get
|
|
42
|
+
started, or request access at [cfdpilot.com](https://cfdpilot.com).
|
|
File without changes
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from cfdpilot.client import CFDPilotClient
|
|
3
|
+
from cfdpilot.tools.filesystem import read_file, list_dir
|
|
4
|
+
from cfdpilot.tools.log_parser import parse_log
|
|
5
|
+
from cfdpilot.tools.patcher import patch_dict, generate_diff
|
|
6
|
+
from cfdpilot.tools.runner import run_command, run_solver
|
|
7
|
+
|
|
8
|
+
MAX_ATTEMPTS = 3
|
|
9
|
+
MAX_TOOL_ROUNDS = 18 # per user request: cap agentic tool-rounds → force a final answer instead of looping forever (prevents the "fix and stabilize" runaway burn)
|
|
10
|
+
|
|
11
|
+
TOOL_SCHEMAS = [
|
|
12
|
+
{
|
|
13
|
+
"name": "read_file",
|
|
14
|
+
"description": "Read a file from the OpenFOAM case directory",
|
|
15
|
+
"input_schema": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {"path": {"type": "string"}},
|
|
18
|
+
"required": ["path"]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "list_dir",
|
|
23
|
+
"description": "List files in a directory",
|
|
24
|
+
"input_schema": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {"path": {"type": "string", "default": "."}},
|
|
27
|
+
"required": []
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "parse_log",
|
|
32
|
+
"description": "Parse an OpenFOAM log file. Returns structured JSON with residuals, Co number, divergence info.",
|
|
33
|
+
"input_schema": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {"log_path": {"type": "string", "description": "Path to log file, or omit to auto-detect"}},
|
|
36
|
+
"required": []
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "run_command",
|
|
41
|
+
"description": "Run an OpenFOAM command (blockMesh, checkMesh, foamDictionary, etc.). NOT for the main solver.",
|
|
42
|
+
"input_schema": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {"cmd": {"type": "string"}},
|
|
45
|
+
"required": ["cmd"]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "run_solver",
|
|
50
|
+
"description": "Launch the OpenFOAM solver and stream residuals. Auto-stops on convergence or divergence.",
|
|
51
|
+
"input_schema": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"solver": {"type": "string"},
|
|
55
|
+
"max_iterations": {"type": "integer"}
|
|
56
|
+
},
|
|
57
|
+
"required": ["solver"]
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "show_diff",
|
|
62
|
+
"description": "Show a colored diff of proposed changes to a file. ALWAYS call this before patch_dict.",
|
|
63
|
+
"input_schema": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"file_path": {"type": "string"},
|
|
67
|
+
"proposed_content": {"type": "string"}
|
|
68
|
+
},
|
|
69
|
+
"required": ["file_path", "proposed_content"]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "patch_dict",
|
|
74
|
+
"description": "Apply confirmed changes to an OpenFOAM dict file. Creates .bak backup automatically.",
|
|
75
|
+
"input_schema": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"file_path": {"type": "string"},
|
|
79
|
+
"changes": {"type": "object", "description": "key-value pairs to update"}
|
|
80
|
+
},
|
|
81
|
+
"required": ["file_path", "changes"]
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "search_knowledge",
|
|
86
|
+
"description": "Search the CFDpilot knowledge base: 1,000+ CFD-Online threads + OpenFOAM user guide. Use when you need community experience or documentation on a specific OF topic.",
|
|
87
|
+
"input_schema": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {"query": {"type": "string", "description": "What to search for, e.g. 'GAMG divergence kOmegaSST' or 'nOuterCorrectors pimpleFoam'"}},
|
|
90
|
+
"required": ["query"]
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _dispatch_tool(name: str, inputs: dict, cwd: str, display, client=None) -> str:
|
|
97
|
+
"""Execute a tool call and return result as JSON string."""
|
|
98
|
+
try:
|
|
99
|
+
if name == "read_file":
|
|
100
|
+
result = read_file(inputs["path"], cwd=cwd)
|
|
101
|
+
return json.dumps(result)
|
|
102
|
+
|
|
103
|
+
elif name == "list_dir":
|
|
104
|
+
result = list_dir(inputs.get("path", "."), cwd=cwd)
|
|
105
|
+
return json.dumps(result)
|
|
106
|
+
|
|
107
|
+
elif name == "parse_log":
|
|
108
|
+
result = parse_log(inputs.get("log_path"), cwd=cwd)
|
|
109
|
+
return json.dumps(result)
|
|
110
|
+
|
|
111
|
+
elif name == "run_command":
|
|
112
|
+
display.print_action(f"Running: {inputs['cmd']}")
|
|
113
|
+
result = run_command(inputs["cmd"], cwd=cwd)
|
|
114
|
+
return json.dumps(result)
|
|
115
|
+
|
|
116
|
+
elif name == "run_solver":
|
|
117
|
+
display.print_action(f"Launching {inputs['solver']}...")
|
|
118
|
+
result = run_solver(
|
|
119
|
+
inputs["solver"],
|
|
120
|
+
cwd=cwd,
|
|
121
|
+
max_iterations=inputs.get("max_iterations"),
|
|
122
|
+
on_line=display.print_solver_line
|
|
123
|
+
)
|
|
124
|
+
return json.dumps(result)
|
|
125
|
+
|
|
126
|
+
elif name == "show_diff":
|
|
127
|
+
diff = generate_diff(inputs["file_path"], inputs["proposed_content"])
|
|
128
|
+
display.print_diff(diff)
|
|
129
|
+
confirmed = display.confirm("Apply changes?")
|
|
130
|
+
return json.dumps({"diff": diff, "confirmed": confirmed})
|
|
131
|
+
|
|
132
|
+
elif name == "patch_dict":
|
|
133
|
+
display.print_action(f"Patching {inputs['file_path']}")
|
|
134
|
+
try:
|
|
135
|
+
result = patch_dict(inputs["file_path"], inputs["changes"])
|
|
136
|
+
display.print_action(f"Backup saved → {result['backup']}")
|
|
137
|
+
return json.dumps(result)
|
|
138
|
+
except FileNotFoundError as e:
|
|
139
|
+
return json.dumps({"success": False, "error": str(e)})
|
|
140
|
+
|
|
141
|
+
elif name == "search_knowledge":
|
|
142
|
+
query = inputs["query"]
|
|
143
|
+
display.print_action(f"Searching knowledge base: {query}")
|
|
144
|
+
results = client.search(query) if client else []
|
|
145
|
+
return json.dumps({"results": results, "count": len(results)})
|
|
146
|
+
|
|
147
|
+
return json.dumps({"error": f"Unknown tool: {name}"})
|
|
148
|
+
|
|
149
|
+
except KeyError as e:
|
|
150
|
+
return json.dumps({"error": f"Missing required input: {e}"})
|
|
151
|
+
except Exception as e:
|
|
152
|
+
return json.dumps({"error": f"Tool error: {type(e).__name__}: {e}"})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
SESSION_TOKEN_CAP = 250_000
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_case_context(cwd: str, case_info: dict) -> str:
|
|
159
|
+
"""Build a context block injected as the first user message."""
|
|
160
|
+
import os
|
|
161
|
+
lines = [f"Working directory: {cwd}"]
|
|
162
|
+
if case_info.get("flat_structure"):
|
|
163
|
+
files = ", ".join(case_info.get("fields", [])) or "unknown"
|
|
164
|
+
lines.append("Structure: FLAT (files dumped in one directory — no 0/constant/system hierarchy)")
|
|
165
|
+
lines.append(f"Files present: {files}")
|
|
166
|
+
lines.append("Note: read files directly by name (e.g. read_file('U'), read_file('fvSchemes'))")
|
|
167
|
+
else:
|
|
168
|
+
lines.append("Structure: standard OpenFOAM (0/ constant/ system/)")
|
|
169
|
+
if case_info.get("solver") and case_info["solver"] != "unknown":
|
|
170
|
+
lines.append(f"Solver: {case_info['solver']}")
|
|
171
|
+
if case_info.get("turbulence_model") and case_info["turbulence_model"] != "unknown":
|
|
172
|
+
lines.append(f"Turbulence: {case_info['turbulence_model']}")
|
|
173
|
+
if case_info.get("log_file"):
|
|
174
|
+
lines.append(f"Log file: {case_info['log_file']}")
|
|
175
|
+
else:
|
|
176
|
+
lines.append("Log file: none found")
|
|
177
|
+
return "\n".join(lines)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def run_agent_loop(token: str, cwd: str, display, case_info: dict = None) -> None:
|
|
181
|
+
"""Main conversation loop. Runs until user exits."""
|
|
182
|
+
client = CFDPilotClient(token)
|
|
183
|
+
messages = []
|
|
184
|
+
session_tokens = 0
|
|
185
|
+
|
|
186
|
+
# Inject case context as a silent system message (not shown to user)
|
|
187
|
+
if case_info:
|
|
188
|
+
ctx = _build_case_context(cwd, case_info)
|
|
189
|
+
messages.append({
|
|
190
|
+
"role": "user",
|
|
191
|
+
"content": f"[CASE CONTEXT — read this before answering anything]\n{ctx}"
|
|
192
|
+
})
|
|
193
|
+
messages.append({
|
|
194
|
+
"role": "assistant",
|
|
195
|
+
"content": [{"type": "text", "text": "Case context noted. Ready."}]
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
display.print_welcome()
|
|
199
|
+
|
|
200
|
+
while True:
|
|
201
|
+
try:
|
|
202
|
+
user_input = display.get_input()
|
|
203
|
+
except (KeyboardInterrupt, EOFError):
|
|
204
|
+
display.print_goodbye()
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
if user_input.lower() in ("exit", "quit", "q"):
|
|
208
|
+
display.print_goodbye()
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
if not user_input.strip():
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
messages.append({"role": "user", "content": user_input})
|
|
215
|
+
attempt_count = 0 # Reset per user turn
|
|
216
|
+
tool_rounds = 0 # Reset per user turn — caps the agentic loop
|
|
217
|
+
|
|
218
|
+
# Agentic inner loop: keep going until no more tool calls
|
|
219
|
+
while True:
|
|
220
|
+
if session_tokens >= SESSION_TOKEN_CAP:
|
|
221
|
+
display.print_error(
|
|
222
|
+
f"Session limit reached ({session_tokens:,} tokens). "
|
|
223
|
+
"Start a new session: cfdpilot"
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
with display.thinking():
|
|
229
|
+
response = client.chat(messages, TOOL_SCHEMAS)
|
|
230
|
+
except RuntimeError as e:
|
|
231
|
+
display.print_error(str(e))
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
usage = response.get("usage", {})
|
|
235
|
+
# Count only genuinely NEW tokens. cache_read/cache_creation are the SAME
|
|
236
|
+
# context re-read across the many agentic sub-calls — counting them blew the
|
|
237
|
+
# cap after ~3 prompts ("session times out"). Cost is capped server-side anyway.
|
|
238
|
+
session_tokens += (
|
|
239
|
+
usage.get("input_tokens", 0)
|
|
240
|
+
+ usage.get("output_tokens", 0)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
content = response.get("content", [])
|
|
244
|
+
stop_reason = response.get("stop_reason", "end_turn")
|
|
245
|
+
credits = response.get("credits")
|
|
246
|
+
|
|
247
|
+
# Display text blocks
|
|
248
|
+
for block in content:
|
|
249
|
+
if block.get("type") == "text":
|
|
250
|
+
display.print_text(block["text"])
|
|
251
|
+
|
|
252
|
+
# Add assistant turn to history
|
|
253
|
+
messages.append({"role": "assistant", "content": content})
|
|
254
|
+
|
|
255
|
+
# No tool calls → turn done
|
|
256
|
+
if stop_reason != "tool_use":
|
|
257
|
+
display.print_credits(credits)
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
# Extract and execute tool calls
|
|
261
|
+
tool_results = []
|
|
262
|
+
stuck = False
|
|
263
|
+
for block in content:
|
|
264
|
+
if block.get("type") != "tool_use":
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
tool_name = block["name"]
|
|
268
|
+
tool_inputs = block.get("input", {})
|
|
269
|
+
tool_id = block["id"]
|
|
270
|
+
|
|
271
|
+
display.print_action(f"● {tool_name}...")
|
|
272
|
+
|
|
273
|
+
# STUCK tracking
|
|
274
|
+
if tool_name == "patch_dict":
|
|
275
|
+
attempt_count += 1
|
|
276
|
+
if attempt_count > MAX_ATTEMPTS:
|
|
277
|
+
display.print_stuck(attempt_count)
|
|
278
|
+
tool_results.append({
|
|
279
|
+
"type": "tool_result",
|
|
280
|
+
"tool_use_id": tool_id,
|
|
281
|
+
"content": json.dumps({"error": "MAX_ATTEMPTS_REACHED"})
|
|
282
|
+
})
|
|
283
|
+
stuck = True
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
result = _dispatch_tool(tool_name, tool_inputs, cwd, display, client)
|
|
287
|
+
tool_results.append({
|
|
288
|
+
"type": "tool_result",
|
|
289
|
+
"tool_use_id": tool_id,
|
|
290
|
+
"content": result # full result sent to model this turn
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
# Truncate tool results before storing in history to prevent context bloat.
|
|
294
|
+
# The model already processed the full content this turn.
|
|
295
|
+
MAX_TOOL_RESULT_CHARS = 1500
|
|
296
|
+
history_tool_results = []
|
|
297
|
+
for tr in tool_results:
|
|
298
|
+
content = tr["content"]
|
|
299
|
+
if len(content) > MAX_TOOL_RESULT_CHARS:
|
|
300
|
+
content = content[:MAX_TOOL_RESULT_CHARS] + "…[truncated]"
|
|
301
|
+
history_tool_results.append({**tr, "content": content})
|
|
302
|
+
tool_rounds += 1
|
|
303
|
+
cap_hit = tool_rounds >= MAX_TOOL_ROUNDS
|
|
304
|
+
if cap_hit and history_tool_results:
|
|
305
|
+
# Inject the wrap-up instruction INTO the last tool_result (stays one user turn,
|
|
306
|
+
# satisfies the API's tool_result requirement) rather than as a 2nd user message.
|
|
307
|
+
history_tool_results[-1] = {
|
|
308
|
+
**history_tool_results[-1],
|
|
309
|
+
"content": history_tool_results[-1]["content"]
|
|
310
|
+
+ "\n\n[Step limit reached for this request — do NOT request more tools. "
|
|
311
|
+
"Give your best final answer now: confirmed issues + exact fixes, and note anything still unresolved.]"
|
|
312
|
+
}
|
|
313
|
+
messages.append({"role": "user", "content": history_tool_results})
|
|
314
|
+
|
|
315
|
+
if stuck:
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
if cap_hit:
|
|
319
|
+
display.print_action(f"Step limit ({MAX_TOOL_ROUNDS} tool rounds) reached — wrapping up with findings so far.")
|
|
320
|
+
try:
|
|
321
|
+
with display.thinking():
|
|
322
|
+
final = client.chat(messages, []) # no tools → model must answer in text
|
|
323
|
+
except RuntimeError as e:
|
|
324
|
+
display.print_error(str(e))
|
|
325
|
+
break
|
|
326
|
+
for fb in final.get("content", []):
|
|
327
|
+
if fb.get("type") == "text":
|
|
328
|
+
display.print_text(fb["text"])
|
|
329
|
+
messages.append({"role": "assistant", "content": final.get("content", [])})
|
|
330
|
+
display.print_credits(final.get("credits"))
|
|
331
|
+
break
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import httpx
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
TOKEN_PATH = Path.home() / ".cfdpilot" / "config"
|
|
7
|
+
API_BASE = "https://cfdpilot.com"
|
|
8
|
+
POLL_INTERVAL = 2
|
|
9
|
+
POLL_TIMEOUT = 600
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def save_token(token: str) -> None:
|
|
13
|
+
TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
TOKEN_PATH.write_text(json.dumps({"token": token}))
|
|
15
|
+
TOKEN_PATH.chmod(0o600)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_token() -> str | None:
|
|
19
|
+
if not TOKEN_PATH.exists():
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(TOKEN_PATH.read_text()).get("token")
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def device_login() -> str:
|
|
28
|
+
"""Run device flow. Blocks until authorized or timeout."""
|
|
29
|
+
try:
|
|
30
|
+
with httpx.Client(timeout=10) as client:
|
|
31
|
+
resp = client.post(f"{API_BASE}/device/code")
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
data = resp.json()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise ConnectionError(f"Could not reach cfdpilot.com: {e}") from e
|
|
36
|
+
|
|
37
|
+
code = data["code"]
|
|
38
|
+
url = data["url"]
|
|
39
|
+
print(f"\n Open this URL in your browser:\n {url}\n")
|
|
40
|
+
print(" Waiting for authentication", end="", flush=True)
|
|
41
|
+
|
|
42
|
+
deadline = time.time() + POLL_TIMEOUT
|
|
43
|
+
with httpx.Client() as client:
|
|
44
|
+
while time.time() < deadline:
|
|
45
|
+
time.sleep(POLL_INTERVAL)
|
|
46
|
+
print(".", end="", flush=True)
|
|
47
|
+
try:
|
|
48
|
+
resp = client.get(f"{API_BASE}/device/poll/{code}")
|
|
49
|
+
if resp.status_code == 200:
|
|
50
|
+
data = resp.json()
|
|
51
|
+
if data["status"] == "authorized":
|
|
52
|
+
print(" ✓")
|
|
53
|
+
return data["token"]
|
|
54
|
+
except Exception:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
raise TimeoutError("Authentication timed out. Run `cfdpilot login` again.")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from cfdpilot.tools.log_parser import _find_log
|
|
4
|
+
|
|
5
|
+
SOLVER_PATTERN = re.compile(r"application\s+(\w+);")
|
|
6
|
+
TURBULENCE_PATTERN = re.compile(r"RASModel\s+(\w+);|LESModel\s+(\w+);|model\s+(\w+);")
|
|
7
|
+
DELTA_T_PATTERN = re.compile(r"deltaT\s+([\d.eE+-]+);")
|
|
8
|
+
END_TIME_PATTERN = re.compile(r"endTime\s+([\d.eE+-]+);")
|
|
9
|
+
|
|
10
|
+
# OF11+ uses a generic runner (`application foamRun;`) with the real physics solver
|
|
11
|
+
# in the `solver` entry (e.g. `solver incompressibleVoF;`). Report both so the agent
|
|
12
|
+
# knows the physics (VoF/compressible/…) AND that the launch command is still foamRun.
|
|
13
|
+
SOLVER_MODULE_PATTERN = re.compile(r"^\s*solver\s+(\w+);", re.MULTILINE)
|
|
14
|
+
_GENERIC_RUNNERS = {"foamRun", "foamMultiRun"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _detect_solver(ctrl: str) -> str:
|
|
18
|
+
m = SOLVER_PATTERN.search(ctrl)
|
|
19
|
+
app = m.group(1) if m else None
|
|
20
|
+
if app in _GENERIC_RUNNERS:
|
|
21
|
+
sm = SOLVER_MODULE_PATTERN.search(ctrl)
|
|
22
|
+
if sm:
|
|
23
|
+
return f"{app} ({sm.group(1)})"
|
|
24
|
+
return app or "unknown"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_OF_FLAT_MARKERS = {"controlDict", "fvSchemes", "fvSolution", "turbulenceProperties", "momentumTransport"}
|
|
28
|
+
_OF_BC_FILES = {"U", "p", "T", "k", "omega", "epsilon", "nut", "alphat", "nuTilda", "p_rgh"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_case(cwd: str) -> dict:
|
|
32
|
+
base = Path(cwd)
|
|
33
|
+
required = [base / "system" / "controlDict", base / "constant", base / "0"]
|
|
34
|
+
|
|
35
|
+
# Standard OF structure
|
|
36
|
+
if all(p.exists() for p in required):
|
|
37
|
+
pass # proceed below
|
|
38
|
+
else:
|
|
39
|
+
# Flat structure: files dumped in one directory (forum thread, shared files)
|
|
40
|
+
flat_files = {f.name for f in base.iterdir() if f.is_file()}
|
|
41
|
+
has_markers = bool(flat_files & _OF_FLAT_MARKERS)
|
|
42
|
+
has_bc = bool(flat_files & _OF_BC_FILES)
|
|
43
|
+
if not (has_markers or has_bc):
|
|
44
|
+
return {"is_of_case": False}
|
|
45
|
+
# Flat case detected — build result directly
|
|
46
|
+
result = {"is_of_case": True, "cwd": cwd, "flat_structure": True}
|
|
47
|
+
ctrl_path = base / "controlDict"
|
|
48
|
+
if ctrl_path.exists():
|
|
49
|
+
ctrl = ctrl_path.read_text(errors="replace")
|
|
50
|
+
result["solver"] = _detect_solver(ctrl)
|
|
51
|
+
m = DELTA_T_PATTERN.search(ctrl)
|
|
52
|
+
result["deltaT"] = float(m.group(1)) if m else None
|
|
53
|
+
m = END_TIME_PATTERN.search(ctrl)
|
|
54
|
+
result["endTime"] = float(m.group(1)) if m else None
|
|
55
|
+
else:
|
|
56
|
+
result["solver"] = "unknown"
|
|
57
|
+
for turb_name in ("turbulenceProperties", "momentumTransport"):
|
|
58
|
+
turb_path = base / turb_name
|
|
59
|
+
if turb_path.exists():
|
|
60
|
+
turb = turb_path.read_text(errors="replace")
|
|
61
|
+
m = TURBULENCE_PATTERN.search(turb)
|
|
62
|
+
result["turbulence_model"] = next((g for g in m.groups() if g), "unknown") if m else "unknown"
|
|
63
|
+
result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
|
|
64
|
+
break
|
|
65
|
+
else:
|
|
66
|
+
result["turbulence_model"] = "unknown"
|
|
67
|
+
result["mesh_type"] = "unknown"
|
|
68
|
+
result["log_file"] = None
|
|
69
|
+
result["fields"] = sorted(flat_files & _OF_BC_FILES)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
result = {"is_of_case": True, "cwd": cwd}
|
|
73
|
+
|
|
74
|
+
ctrl = (base / "system" / "controlDict").read_text(errors="replace")
|
|
75
|
+
result["solver"] = _detect_solver(ctrl)
|
|
76
|
+
|
|
77
|
+
m = DELTA_T_PATTERN.search(ctrl)
|
|
78
|
+
result["deltaT"] = float(m.group(1)) if m else None
|
|
79
|
+
|
|
80
|
+
m = END_TIME_PATTERN.search(ctrl)
|
|
81
|
+
result["endTime"] = float(m.group(1)) if m else None
|
|
82
|
+
|
|
83
|
+
turb_file = base / "constant" / "turbulenceProperties"
|
|
84
|
+
if not turb_file.exists():
|
|
85
|
+
turb_file = base / "constant" / "momentumTransport"
|
|
86
|
+
result["turbulence_model"] = "unknown"
|
|
87
|
+
if turb_file.exists():
|
|
88
|
+
turb = turb_file.read_text(errors="replace")
|
|
89
|
+
m = TURBULENCE_PATTERN.search(turb)
|
|
90
|
+
if m:
|
|
91
|
+
result["turbulence_model"] = next(g for g in m.groups() if g)
|
|
92
|
+
result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
|
|
93
|
+
|
|
94
|
+
result["mesh_type"] = "blockMesh"
|
|
95
|
+
if (base / "system" / "snappyHexMeshDict").exists():
|
|
96
|
+
result["mesh_type"] = "snappyHexMesh"
|
|
97
|
+
|
|
98
|
+
log = _find_log(cwd)
|
|
99
|
+
result["log_file"] = str(log) if log else None
|
|
100
|
+
|
|
101
|
+
fields = list((base / "0").iterdir())
|
|
102
|
+
result["fields"] = [f.name for f in fields if f.is_file()]
|
|
103
|
+
|
|
104
|
+
return result
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
API_BASE = "https://cfdpilot.com"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CFDPilotClient:
|
|
9
|
+
def __init__(self, token: str):
|
|
10
|
+
self.token = token
|
|
11
|
+
self.session_id = str(uuid.uuid4())
|
|
12
|
+
self.headers = {
|
|
13
|
+
"Authorization": f"Bearer {token}",
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def search(self, query: str) -> list[dict]:
|
|
18
|
+
"""GET /agent/search — query the RAG knowledge base."""
|
|
19
|
+
try:
|
|
20
|
+
with httpx.Client(timeout=30) as client:
|
|
21
|
+
response = client.get(
|
|
22
|
+
f"{API_BASE}/agent/search",
|
|
23
|
+
headers=self.headers,
|
|
24
|
+
params={"query": query},
|
|
25
|
+
)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
return response.json().get("results", [])
|
|
28
|
+
except Exception:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
def chat(self, messages: list[dict], tools: list[dict]) -> dict:
|
|
32
|
+
"""POST to /agent/chat, return the response dict."""
|
|
33
|
+
try:
|
|
34
|
+
with httpx.Client(timeout=120) as client:
|
|
35
|
+
response = client.post(
|
|
36
|
+
f"{API_BASE}/agent/chat",
|
|
37
|
+
headers=self.headers,
|
|
38
|
+
json={"messages": messages, "tools": tools, "session_id": self.session_id},
|
|
39
|
+
)
|
|
40
|
+
if response.status_code in (401, 402, 403, 429):
|
|
41
|
+
try:
|
|
42
|
+
detail = response.json().get("detail")
|
|
43
|
+
except Exception:
|
|
44
|
+
detail = None
|
|
45
|
+
raise RuntimeError(detail or "No beta access yet — email rayan@cfdpilot.com")
|
|
46
|
+
response.raise_for_status()
|
|
47
|
+
return response.json()
|
|
48
|
+
except httpx.TimeoutException:
|
|
49
|
+
raise RuntimeError("Request timed out. The backend may be overloaded.")
|
|
50
|
+
except httpx.HTTPStatusError as e:
|
|
51
|
+
raise RuntimeError(f"Backend error: HTTP {e.response.status_code}")
|
|
52
|
+
except httpx.NetworkError as e:
|
|
53
|
+
raise RuntimeError(f"Network error: {e}")
|
|
54
|
+
|
|
55
|
+
def report_error(self, error: str, tb: str) -> None:
|
|
56
|
+
"""Best-effort: report a CLI crash to the backend so it's logged. Never raises."""
|
|
57
|
+
try:
|
|
58
|
+
with httpx.Client(timeout=10) as client:
|
|
59
|
+
client.post(
|
|
60
|
+
f"{API_BASE}/agent/error",
|
|
61
|
+
headers=self.headers,
|
|
62
|
+
json={"session_id": self.session_id, "error": error, "traceback": tb},
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|