KaiWu commited on
Commit
105f2e1
·
1 Parent(s): 21d491d

refactor: 拆分 agent_loop.py 为 agent_core 包与 cli 入口

Browse files

- 将主循环与工具调度抽到 agent_core.agent
- 常量与客户端工厂抽到 agent_core.config
- SYSTEM prompt 抽到 agent_core.prompts
- run_dir/manifest 相关抽到 agent_core.outputs
- 通用 json_response/tail_text 抽到 agent_core.utils
- CadQuery 与 Lux3D 工具分别放到 agent_core.tools.{cadquery_tool,lux3d_tool}
- 新增 cli.py 作为终端入口
- agent_loop.py 保留为向后兼容壳,维持 python agent_loop.py 可用

agent_core/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Core package for the CAD and 3D generation agent."""
2
+
agent_core/agent.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agent_core.config import DEFAULT_CADQUERY_OUTPUT_PATH, get_client, get_model_id
2
+ from agent_core.prompts import SYSTEM
3
+ from agent_core.tools.cadquery_tool import TOOL_SCHEMA as CADQUERY_TOOL_SCHEMA
4
+ from agent_core.tools.cadquery_tool import run_execute_cadquery
5
+ from agent_core.tools.lux3d_tool import TOOL_SCHEMA as LUX3D_TOOL_SCHEMA
6
+ from agent_core.tools.lux3d_tool import run_generate_3d_model
7
+
8
+
9
+ TOOL_HANDLERS = {
10
+ "execute_cadquery": lambda **kw: run_execute_cadquery(
11
+ kw["code"],
12
+ kw.get("output_path", DEFAULT_CADQUERY_OUTPUT_PATH),
13
+ ),
14
+ "generate_3d_model": lambda **kw: run_generate_3d_model(
15
+ kw["image_path"],
16
+ kw.get("output_path"),
17
+ ),
18
+ }
19
+
20
+ TOOLS = [
21
+ CADQUERY_TOOL_SCHEMA,
22
+ LUX3D_TOOL_SCHEMA,
23
+ ]
24
+
25
+
26
+ def latest_user_prompt(messages: list) -> str | None:
27
+ for message in reversed(messages):
28
+ if message.get("role") == "user" and isinstance(message.get("content"), str):
29
+ return message["content"]
30
+ return None
31
+
32
+
33
+ def agent_loop(messages: list):
34
+ client = get_client()
35
+ model = get_model_id()
36
+
37
+ while True:
38
+ response = client.messages.create(
39
+ model=model,
40
+ system=SYSTEM,
41
+ messages=messages,
42
+ tools=TOOLS,
43
+ max_tokens=8000,
44
+ )
45
+ messages.append({"role": "assistant", "content": response.content})
46
+ if response.stop_reason != "tool_use":
47
+ return
48
+
49
+ results = []
50
+ prompt = latest_user_prompt(messages)
51
+ for block in response.content:
52
+ if block.type == "tool_use":
53
+ if block.name == "execute_cadquery":
54
+ output = run_execute_cadquery(
55
+ code=block.input["code"],
56
+ output_path=block.input.get("output_path", DEFAULT_CADQUERY_OUTPUT_PATH),
57
+ prompt=prompt,
58
+ )
59
+ elif block.name == "generate_3d_model":
60
+ output = run_generate_3d_model(
61
+ image_path=block.input["image_path"],
62
+ output_path=block.input.get("output_path"),
63
+ prompt=prompt,
64
+ )
65
+ else:
66
+ handler = TOOL_HANDLERS.get(block.name)
67
+ output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
68
+
69
+ print(f"> {block.name}: {output[:200]}")
70
+ results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
71
+ messages.append({"role": "user", "content": results})
72
+
agent_core/config.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ try:
5
+ from dotenv import load_dotenv
6
+ except ImportError:
7
+ def load_dotenv(*args, **kwargs):
8
+ return False
9
+
10
+
11
+ load_dotenv(override=True)
12
+
13
+ if os.getenv("ANTHROPIC_BASE_URL"):
14
+ os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
15
+
16
+
17
+ WORKDIR = Path.cwd()
18
+ DEFAULT_CADQUERY_OUTPUT_PATH = "outputs/model.step"
19
+ DEFAULT_LUX3D_OUTPUT_PATH = "outputs"
20
+ STEP_SUFFIXES = (".step", ".stp")
21
+ IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp")
22
+
23
+ LUX3D_API_KEY_ENV = "LUX3D_API_KEY"
24
+ LUX3D_CREATE_URL = "https://api.luxreal.ai/global/lux3d/generate/task/create"
25
+ LUX3D_GET_URL = "https://api.luxreal.ai/global/lux3d/generate/task/get"
26
+ LUX3D_POLL_INTERVAL_SECONDS = 12
27
+ LUX3D_TIMEOUT_SECONDS = 600
28
+
29
+
30
+ def get_client():
31
+ from anthropic import Anthropic
32
+
33
+ return Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
34
+
35
+
36
+ def get_model_id() -> str:
37
+ return os.environ["MODEL_ID"]
38
+
agent_core/outputs.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+ from agent_core.config import DEFAULT_LUX3D_OUTPUT_PATH, STEP_SUFFIXES, WORKDIR
7
+
8
+
9
+ def safe_path(p: str) -> Path:
10
+ path = (WORKDIR / p).resolve()
11
+ if not path.is_relative_to(WORKDIR):
12
+ raise ValueError(f"Path escapes workspace: {p}")
13
+ return path
14
+
15
+
16
+ def workspace_relative(path: Path) -> str:
17
+ return str(path.relative_to(WORKDIR))
18
+
19
+
20
+ def new_run_id() -> str:
21
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
22
+ return f"{timestamp}_{uuid4().hex[:6]}"
23
+
24
+
25
+ def allocate_run_dir(output_root: Path) -> tuple[Path, str]:
26
+ for _ in range(20):
27
+ run_id = new_run_id()
28
+ run_dir = output_root / "runs" / run_id
29
+ if not run_dir.exists():
30
+ return run_dir, run_id
31
+
32
+ raise RuntimeError("Failed to allocate a unique output run directory.")
33
+
34
+
35
+ def resolve_run_output(requested_output_path: str) -> tuple[Path, Path, str]:
36
+ requested_path = safe_path(requested_output_path)
37
+ suffix = requested_path.suffix.lower()
38
+
39
+ if suffix in STEP_SUFFIXES:
40
+ output_root = requested_path.parent
41
+ output_name = requested_path.name
42
+ elif suffix:
43
+ raise ValueError("Only STEP output is supported in this version. Use a .step/.stp file path or a directory path.")
44
+ else:
45
+ output_root = requested_path
46
+ output_name = "model.step"
47
+
48
+ run_dir, run_id = allocate_run_dir(output_root)
49
+ return run_dir, run_dir / output_name, run_id
50
+
51
+
52
+ def resolve_lux3d_run(output_path: str | None) -> tuple[Path, Path, str, str | None, str]:
53
+ requested_output_path = output_path or DEFAULT_LUX3D_OUTPUT_PATH
54
+ requested_path = safe_path(requested_output_path)
55
+ if requested_path.suffix:
56
+ output_root = requested_path.parent
57
+ output_name = requested_path.name
58
+ else:
59
+ output_root = requested_path
60
+ output_name = None
61
+
62
+ run_dir, run_id = allocate_run_dir(output_root)
63
+ return output_root, run_dir, run_id, output_name, requested_output_path
64
+
65
+
66
+ def update_latest_link(output_root: Path, run_dir: Path) -> str | None:
67
+ latest = output_root / "latest"
68
+ try:
69
+ if latest.is_symlink() or latest.is_file():
70
+ latest.unlink()
71
+ elif latest.exists():
72
+ return f"Skipped latest link because {workspace_relative(latest)} already exists and is not a symlink."
73
+
74
+ latest.symlink_to(Path("runs") / run_dir.name, target_is_directory=True)
75
+ except OSError as exc:
76
+ return f"Failed to update latest link: {exc}"
77
+
78
+ return None
79
+
80
+
81
+ def write_manifest(
82
+ run_dir: Path,
83
+ run_id: str,
84
+ requested_output_path: str,
85
+ output_path: Path,
86
+ code: str,
87
+ prompt: str | None,
88
+ payload: dict,
89
+ ) -> Path:
90
+ manifest_path = run_dir / "manifest.json"
91
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
92
+ manifest = {
93
+ "run_id": run_id,
94
+ "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
95
+ "prompt": prompt,
96
+ "requested_output_path": requested_output_path,
97
+ "output_path": workspace_relative(output_path),
98
+ "format": output_path.suffix.lstrip(".").lower(),
99
+ "code": code,
100
+ "stdout": payload.get("stdout", ""),
101
+ "stderr": payload.get("stderr", ""),
102
+ }
103
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
104
+ return manifest_path
105
+
106
+
107
+ def write_lux3d_manifest(
108
+ run_dir: Path,
109
+ run_id: str,
110
+ prompt: str | None,
111
+ image_path: Path,
112
+ requested_output_path: str,
113
+ output_path: Path | None,
114
+ busid: str | int | None,
115
+ status: int | None,
116
+ result_url: str | None,
117
+ poll_count: int,
118
+ error: str | None = None,
119
+ ) -> Path:
120
+ manifest_path = run_dir / "manifest.json"
121
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
122
+ manifest = {
123
+ "run_id": run_id,
124
+ "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
125
+ "generator": "lux3d",
126
+ "prompt": prompt,
127
+ "image_path": workspace_relative(image_path),
128
+ "requested_output_path": requested_output_path,
129
+ "output_path": workspace_relative(output_path) if output_path else None,
130
+ "busid": busid,
131
+ "status": status,
132
+ "result_url": result_url,
133
+ "poll_count": poll_count,
134
+ "error": error,
135
+ }
136
+ manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
137
+ return manifest_path
138
+
agent_core/prompts.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agent_core.config import WORKDIR
2
+
3
+
4
+ SYSTEM = f"""
5
+ You are a CAD and 3D model generation agent at {WORKDIR}.
6
+
7
+ You have two tools:
8
+ - execute_cadquery: use it for precise CAD, dimensioned parts, parametric geometry, engineering models, and STEP/CAD output.
9
+ - generate_3d_model: use it when the user provides an image path and asks for a 3D model, mesh, GLB, OBJ, or other non-precise 3D asset.
10
+
11
+ For CadQuery tasks:
12
+ - Write CadQuery Python code.
13
+ - The code must assign the final model to a variable named result.
14
+ - You may use cadquery as cq; it is pre-imported by the tool.
15
+ - Call execute_cadquery with the code and optional output_path.
16
+ - Each successful tool call writes into a unique run directory to avoid overwriting previous models.
17
+ - If execute_cadquery returns ok=false, inspect the structured error, fix the code, and call execute_cadquery again.
18
+
19
+ For image-to-3D tasks:
20
+ - The user must provide a workspace-relative image path.
21
+ - If the user asks for image-to-3D generation but does not provide an image path, ask for the image path. Do not guess.
22
+ - Call generate_3d_model with the image_path and optional output_path.
23
+ - Do not ask the user to read files, convert images to base64, call APIs, or download results manually.
24
+
25
+ When a tool returns ok=true, report output_path, run_dir, and manifest_path to the user.
26
+
27
+ Do not ask the user to run commands or create files manually.
28
+ """.strip()
29
+
agent_core/tools/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Tool implementations for the agent runtime."""
2
+
agent_core/tools/cadquery_tool.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ import textwrap
5
+ from pathlib import Path
6
+
7
+ from agent_core.config import DEFAULT_CADQUERY_OUTPUT_PATH, WORKDIR
8
+ from agent_core.outputs import resolve_run_output, update_latest_link, workspace_relative, write_manifest
9
+ from agent_core.utils import json_response
10
+
11
+
12
+ CADQUERY_RUNNER = r"""
13
+ import contextlib
14
+ import io
15
+ import json
16
+ import sys
17
+ import traceback
18
+ from pathlib import Path
19
+
20
+ stdout_buffer = io.StringIO()
21
+ stderr_buffer = io.StringIO()
22
+
23
+
24
+ def tail(text, limit=4000):
25
+ if not text:
26
+ return ""
27
+ return text[-limit:]
28
+
29
+
30
+ def fail(stage, exc=None, error_type=None, error=None):
31
+ payload = {
32
+ "ok": False,
33
+ "stage": stage,
34
+ "error_type": error_type or (type(exc).__name__ if exc else "Error"),
35
+ "error": error or (str(exc) if exc else ""),
36
+ "traceback_tail": tail(traceback.format_exc() if exc else ""),
37
+ "stdout": tail(stdout_buffer.getvalue()),
38
+ "stderr": tail(stderr_buffer.getvalue()),
39
+ }
40
+ print(json.dumps(payload, ensure_ascii=False))
41
+
42
+
43
+ try:
44
+ request = json.loads(sys.stdin.read())
45
+ code = request["code"]
46
+ output_path = Path(request["output_path"])
47
+ except Exception as exc:
48
+ fail("input", exc)
49
+ sys.exit(0)
50
+
51
+ try:
52
+ import cadquery as cq
53
+ except Exception as exc:
54
+ fail("import", exc)
55
+ sys.exit(0)
56
+
57
+ try:
58
+ compiled = compile(code, "<cadquery_code>", "exec")
59
+ except Exception as exc:
60
+ fail("syntax", exc)
61
+ sys.exit(0)
62
+
63
+ namespace = {
64
+ "__name__": "__cadquery_user_code__",
65
+ "cq": cq,
66
+ }
67
+
68
+ try:
69
+ with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):
70
+ exec(compiled, namespace)
71
+ except Exception as exc:
72
+ fail("execution", exc)
73
+ sys.exit(0)
74
+
75
+ if "result" not in namespace:
76
+ fail("result", error_type="MissingResult", error="CadQuery code must assign the final model to variable 'result'.")
77
+ sys.exit(0)
78
+
79
+ result = namespace["result"]
80
+ if result is None:
81
+ fail("result", error_type="InvalidResult", error="'result' is None.")
82
+ sys.exit(0)
83
+
84
+ exportable_types = tuple(
85
+ value
86
+ for name in ("Workplane", "Shape", "Assembly", "Sketch")
87
+ for value in [getattr(cq, name, None)]
88
+ if isinstance(value, type)
89
+ )
90
+
91
+ is_exportable = isinstance(result, exportable_types)
92
+ if isinstance(result, (list, tuple)) and result:
93
+ is_exportable = all(isinstance(item, exportable_types) for item in result)
94
+
95
+ if not is_exportable:
96
+ fail(
97
+ "result",
98
+ error_type="InvalidResultType",
99
+ error=f"'result' must be a CadQuery object or a non-empty list/tuple of CadQuery objects, got {type(result).__name__}.",
100
+ )
101
+ sys.exit(0)
102
+
103
+ try:
104
+ output_path.parent.mkdir(parents=True, exist_ok=True)
105
+ cq.exporters.export(result, str(output_path))
106
+ except Exception as exc:
107
+ fail("export", exc)
108
+ sys.exit(0)
109
+
110
+ payload = {
111
+ "ok": True,
112
+ "stage": "done",
113
+ "output_path": str(output_path),
114
+ "stdout": tail(stdout_buffer.getvalue()),
115
+ "stderr": tail(stderr_buffer.getvalue()),
116
+ }
117
+ print(json.dumps(payload, ensure_ascii=False))
118
+ """
119
+
120
+
121
+ def run_execute_cadquery(code: str, output_path: str = DEFAULT_CADQUERY_OUTPUT_PATH, prompt: str | None = None) -> str:
122
+ try:
123
+ if not code or not code.strip():
124
+ return json_response({
125
+ "ok": False,
126
+ "stage": "input",
127
+ "error_type": "EmptyCode",
128
+ "error": "execute_cadquery requires non-empty CadQuery code.",
129
+ "traceback_tail": "",
130
+ "stdout": "",
131
+ "stderr": "",
132
+ })
133
+
134
+ run_dir, output, run_id = resolve_run_output(output_path)
135
+ output_root = run_dir.parent.parent
136
+ except Exception as exc:
137
+ return json_response({
138
+ "ok": False,
139
+ "stage": "input",
140
+ "error_type": type(exc).__name__,
141
+ "error": str(exc),
142
+ "traceback_tail": "",
143
+ "stdout": "",
144
+ "stderr": "",
145
+ })
146
+
147
+ request = {
148
+ "code": code,
149
+ "output_path": str(output),
150
+ }
151
+
152
+ try:
153
+ process = subprocess.run(
154
+ [sys.executable, "-c", CADQUERY_RUNNER],
155
+ input=json.dumps(request, ensure_ascii=False),
156
+ cwd=WORKDIR,
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=120,
160
+ )
161
+ except subprocess.TimeoutExpired as exc:
162
+ return json_response({
163
+ "ok": False,
164
+ "stage": "execution",
165
+ "error_type": "TimeoutExpired",
166
+ "error": "CadQuery execution timed out after 120 seconds.",
167
+ "traceback_tail": "",
168
+ "stdout": exc.stdout[-4000:] if exc.stdout else "",
169
+ "stderr": exc.stderr[-4000:] if exc.stderr else "",
170
+ })
171
+ except Exception as exc:
172
+ return json_response({
173
+ "ok": False,
174
+ "stage": "subprocess",
175
+ "error_type": type(exc).__name__,
176
+ "error": str(exc),
177
+ "traceback_tail": "",
178
+ "stdout": "",
179
+ "stderr": "",
180
+ })
181
+
182
+ raw_output = process.stdout.strip()
183
+ if not raw_output:
184
+ return json_response({
185
+ "ok": False,
186
+ "stage": "subprocess",
187
+ "error_type": "NoRunnerOutput",
188
+ "error": "CadQuery runner returned no JSON output.",
189
+ "traceback_tail": "",
190
+ "stdout": "",
191
+ "stderr": process.stderr[-4000:],
192
+ })
193
+
194
+ try:
195
+ payload = json.loads(raw_output.splitlines()[-1])
196
+ except Exception as exc:
197
+ return json_response({
198
+ "ok": False,
199
+ "stage": "subprocess",
200
+ "error_type": type(exc).__name__,
201
+ "error": f"Failed to parse CadQuery runner output: {exc}",
202
+ "traceback_tail": "",
203
+ "stdout": raw_output[-4000:],
204
+ "stderr": process.stderr[-4000:],
205
+ })
206
+
207
+ if process.returncode != 0 and payload.get("ok") is not False:
208
+ payload = {
209
+ "ok": False,
210
+ "stage": "subprocess",
211
+ "error_type": "RunnerFailed",
212
+ "error": f"CadQuery runner exited with code {process.returncode}.",
213
+ "traceback_tail": "",
214
+ "stdout": raw_output[-4000:],
215
+ "stderr": process.stderr[-4000:],
216
+ }
217
+
218
+ if payload.get("ok") and payload.get("output_path"):
219
+ exported_path = Path(payload["output_path"])
220
+ manifest_path = write_manifest(
221
+ run_dir=run_dir,
222
+ run_id=run_id,
223
+ requested_output_path=output_path,
224
+ output_path=exported_path,
225
+ code=code,
226
+ prompt=prompt,
227
+ payload=payload,
228
+ )
229
+ latest_warning = update_latest_link(output_root, run_dir)
230
+
231
+ payload["run_id"] = run_id
232
+ payload["run_dir"] = workspace_relative(run_dir)
233
+ payload["output_path"] = workspace_relative(exported_path)
234
+ payload["manifest_path"] = workspace_relative(manifest_path)
235
+ payload["latest_path"] = workspace_relative(output_root / "latest")
236
+ if latest_warning:
237
+ payload["warning"] = latest_warning
238
+
239
+ return json_response(payload)
240
+
241
+
242
+ TOOL_SCHEMA = {
243
+ "name": "execute_cadquery",
244
+ "description": textwrap.dedent("""
245
+ Execute CadQuery Python code and export the result as a STEP file.
246
+ The code must assign the final CadQuery model to a variable named result.
247
+ cadquery is pre-imported as cq.
248
+ output_path is optional. If it is a .step/.stp file path, the file name is used inside a unique run directory.
249
+ If it is a directory path, model.step is written inside a unique run directory under that root.
250
+ """).strip(),
251
+ "input_schema": {
252
+ "type": "object",
253
+ "properties": {
254
+ "code": {
255
+ "type": "string",
256
+ "description": "CadQuery Python code. It must assign the final model to variable 'result'.",
257
+ },
258
+ "output_path": {
259
+ "type": "string",
260
+ "description": "Optional workspace-relative STEP file path or output directory. Defaults to outputs/model.step.",
261
+ },
262
+ },
263
+ "required": ["code"],
264
+ },
265
+ }
266
+
agent_core/tools/lux3d_tool.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import mimetypes
5
+ import os
6
+ import textwrap
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+
13
+ from agent_core.config import (
14
+ DEFAULT_LUX3D_OUTPUT_PATH,
15
+ IMAGE_SUFFIXES,
16
+ LUX3D_API_KEY_ENV,
17
+ LUX3D_CREATE_URL,
18
+ LUX3D_GET_URL,
19
+ LUX3D_POLL_INTERVAL_SECONDS,
20
+ LUX3D_TIMEOUT_SECONDS,
21
+ )
22
+ from agent_core.outputs import (
23
+ resolve_lux3d_run,
24
+ safe_path,
25
+ update_latest_link,
26
+ workspace_relative,
27
+ write_lux3d_manifest,
28
+ )
29
+ from agent_core.utils import json_response, tail_text
30
+
31
+
32
+ def validate_image_path(image_path: str) -> Path:
33
+ if not image_path or not image_path.strip():
34
+ raise ValueError("image_path is required for image-to-3D generation.")
35
+
36
+ path = safe_path(image_path)
37
+ if not path.exists():
38
+ raise FileNotFoundError(f"Image file does not exist: {image_path}")
39
+ if not path.is_file():
40
+ raise ValueError(f"image_path must be a file: {image_path}")
41
+ if path.suffix.lower() not in IMAGE_SUFFIXES:
42
+ raise ValueError(f"Unsupported image format: {path.suffix}. Use one of: {', '.join(IMAGE_SUFFIXES)}")
43
+ return path
44
+
45
+
46
+ def image_to_data_url(image_path: Path) -> str:
47
+ mime_type = mimetypes.guess_type(str(image_path))[0]
48
+ if not mime_type:
49
+ mime_type = "image/jpeg" if image_path.suffix.lower() in (".jpg", ".jpeg") else f"image/{image_path.suffix.lower().lstrip('.')}"
50
+ encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
51
+ return f"data:{mime_type};base64,{encoded}"
52
+
53
+
54
+ def parse_lux3d_api_key() -> dict:
55
+ api_key = os.getenv(LUX3D_API_KEY_ENV, "").strip()
56
+ if not api_key:
57
+ raise ValueError(f"Missing {LUX3D_API_KEY_ENV}. Set it in your environment or .env file.")
58
+
59
+ try:
60
+ decoded = base64.b64decode(api_key, validate=True).decode("utf-8")
61
+ except Exception as exc:
62
+ raise ValueError(f"Invalid {LUX3D_API_KEY_ENV}: expected a base64-encoded API key.") from exc
63
+
64
+ parts = decoded.split(":")
65
+ if len(parts) != 4 or not all(parts):
66
+ raise ValueError(f"Invalid {LUX3D_API_KEY_ENV}: expected decoded format version:appkey:appsecret:appuid.")
67
+
68
+ version, appkey, appsecret, appuid = parts
69
+ return {
70
+ "version": version,
71
+ "appkey": appkey,
72
+ "appsecret": appsecret,
73
+ "appuid": appuid,
74
+ }
75
+
76
+
77
+ def lux3d_query(credentials: dict, extra: dict | None = None) -> str:
78
+ timestamp = str(int(time.time() * 1000))
79
+ sign = hashlib.md5(
80
+ (credentials["appsecret"] + credentials["appkey"] + credentials["appuid"] + timestamp).encode("utf-8")
81
+ ).hexdigest()
82
+ params = {
83
+ "appuid": credentials["appuid"],
84
+ "appkey": credentials["appkey"],
85
+ "timestamp": timestamp,
86
+ "sign": sign,
87
+ }
88
+ if extra:
89
+ params.update(extra)
90
+ return urllib.parse.urlencode(params)
91
+
92
+
93
+ def lux3d_json_request(method: str, url: str, payload: dict | None = None, timeout: int = 30) -> dict:
94
+ data = None
95
+ headers = {}
96
+ if payload is not None:
97
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
98
+ headers["Content-Type"] = "application/json"
99
+
100
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
101
+ try:
102
+ with urllib.request.urlopen(request, timeout=timeout) as response:
103
+ raw = response.read().decode("utf-8")
104
+ except urllib.error.HTTPError as exc:
105
+ body = exc.read().decode("utf-8", errors="replace")
106
+ raise RuntimeError(f"Lux3D API HTTP {exc.code}: {tail_text(body)}") from exc
107
+ except urllib.error.URLError as exc:
108
+ raise RuntimeError(f"Lux3D API request failed: {exc.reason}") from exc
109
+
110
+ try:
111
+ return json.loads(raw)
112
+ except Exception as exc:
113
+ raise RuntimeError(f"Lux3D API returned invalid JSON: {tail_text(raw)}") from exc
114
+
115
+
116
+ def lux3d_create_task(credentials: dict, image_data_url: str) -> str | int:
117
+ query = lux3d_query(credentials)
118
+ payload = lux3d_json_request("POST", f"{LUX3D_CREATE_URL}?{query}", {"img": image_data_url})
119
+ busid = payload.get("d")
120
+ if not busid:
121
+ raise RuntimeError(f"Lux3D create task response did not include busid: {payload}")
122
+ return busid
123
+
124
+
125
+ def lux3d_get_task(credentials: dict, busid: str | int) -> dict:
126
+ query = lux3d_query(credentials, {"busid": busid})
127
+ payload = lux3d_json_request("GET", f"{LUX3D_GET_URL}?{query}")
128
+ data = payload.get("d")
129
+ if not isinstance(data, dict):
130
+ raise RuntimeError(f"Lux3D get task response did not include task data: {payload}")
131
+ return data
132
+
133
+
134
+ def infer_lux3d_output_name(result_url: str) -> str:
135
+ parsed = urllib.parse.urlparse(result_url)
136
+ name = Path(urllib.parse.unquote(parsed.path)).name
137
+ if name and "." in name:
138
+ return name
139
+ return "model.glb"
140
+
141
+
142
+ def download_lux3d_result(result_url: str, output_path: Path) -> None:
143
+ request = urllib.request.Request(result_url, headers={"User-Agent": "aigc-3dcad/0.1"})
144
+ try:
145
+ with urllib.request.urlopen(request, timeout=120) as response:
146
+ data = response.read()
147
+ except urllib.error.HTTPError as exc:
148
+ body = exc.read().decode("utf-8", errors="replace")
149
+ raise RuntimeError(f"Lux3D result download HTTP {exc.code}: {tail_text(body)}") from exc
150
+ except urllib.error.URLError as exc:
151
+ raise RuntimeError(f"Lux3D result download failed: {exc.reason}") from exc
152
+
153
+ output_path.parent.mkdir(parents=True, exist_ok=True)
154
+ output_path.write_bytes(data)
155
+
156
+
157
+ def run_generate_3d_model(
158
+ image_path: str,
159
+ output_path: str | None = None,
160
+ prompt: str | None = None,
161
+ poll_interval_seconds: int = LUX3D_POLL_INTERVAL_SECONDS,
162
+ timeout_seconds: int = LUX3D_TIMEOUT_SECONDS,
163
+ ) -> str:
164
+ busid = None
165
+ status = None
166
+ result_url = None
167
+ output = None
168
+ poll_count = 0
169
+ run_dir = None
170
+ run_id = None
171
+ requested_output_path = output_path or DEFAULT_LUX3D_OUTPUT_PATH
172
+
173
+ try:
174
+ image = validate_image_path(image_path)
175
+ credentials = parse_lux3d_api_key()
176
+ output_root, run_dir, run_id, requested_output_name, requested_output_path = resolve_lux3d_run(output_path)
177
+ image_data_url = image_to_data_url(image)
178
+ except Exception as exc:
179
+ return json_response({
180
+ "ok": False,
181
+ "stage": "input",
182
+ "error_type": type(exc).__name__,
183
+ "error": str(exc),
184
+ "busid": busid,
185
+ })
186
+
187
+ try:
188
+ busid = lux3d_create_task(credentials, image_data_url)
189
+ deadline = time.time() + timeout_seconds
190
+
191
+ while True:
192
+ if time.time() >= deadline:
193
+ raise TimeoutError(f"Lux3D task timed out after {timeout_seconds} seconds.")
194
+
195
+ time.sleep(poll_interval_seconds)
196
+ poll_count += 1
197
+ task = lux3d_get_task(credentials, busid)
198
+ raw_status = task.get("status")
199
+ status = int(raw_status) if raw_status is not None else None
200
+
201
+ if status in (0, 1):
202
+ continue
203
+
204
+ if status == 3:
205
+ outputs = task.get("outputs") or []
206
+ result_url = next(
207
+ (item.get("content") for item in outputs if isinstance(item, dict) and item.get("content")),
208
+ None,
209
+ )
210
+ if not result_url:
211
+ raise RuntimeError(f"Lux3D task succeeded but did not include a result URL: {task}")
212
+
213
+ output_name = requested_output_name or infer_lux3d_output_name(result_url)
214
+ output = run_dir / output_name
215
+ download_lux3d_result(result_url, output)
216
+ manifest_path = write_lux3d_manifest(
217
+ run_dir=run_dir,
218
+ run_id=run_id,
219
+ prompt=prompt,
220
+ image_path=image,
221
+ requested_output_path=requested_output_path,
222
+ output_path=output,
223
+ busid=busid,
224
+ status=status,
225
+ result_url=result_url,
226
+ poll_count=poll_count,
227
+ )
228
+ latest_warning = update_latest_link(output_root, run_dir)
229
+ payload = {
230
+ "ok": True,
231
+ "stage": "done",
232
+ "run_id": run_id,
233
+ "run_dir": workspace_relative(run_dir),
234
+ "output_path": workspace_relative(output),
235
+ "manifest_path": workspace_relative(manifest_path),
236
+ "latest_path": workspace_relative(output_root / "latest"),
237
+ "busid": busid,
238
+ "status": status,
239
+ "result_url": result_url,
240
+ "poll_count": poll_count,
241
+ }
242
+ if latest_warning:
243
+ payload["warning"] = latest_warning
244
+ return json_response(payload)
245
+
246
+ if status == 4:
247
+ raise RuntimeError(f"Lux3D task failed: {task}")
248
+
249
+ raise RuntimeError(f"Lux3D task returned unknown status {raw_status}: {task}")
250
+ except Exception as exc:
251
+ manifest_path = None
252
+ if run_dir and run_id:
253
+ manifest_path = write_lux3d_manifest(
254
+ run_dir=run_dir,
255
+ run_id=run_id,
256
+ prompt=prompt,
257
+ image_path=image,
258
+ requested_output_path=requested_output_path,
259
+ output_path=output,
260
+ busid=busid,
261
+ status=status,
262
+ result_url=result_url,
263
+ poll_count=poll_count,
264
+ error=str(exc),
265
+ )
266
+
267
+ payload = {
268
+ "ok": False,
269
+ "stage": "lux3d",
270
+ "error_type": type(exc).__name__,
271
+ "error": str(exc),
272
+ "busid": busid,
273
+ "status": status,
274
+ "result_url": result_url,
275
+ "poll_count": poll_count,
276
+ }
277
+ if manifest_path:
278
+ payload["run_id"] = run_id
279
+ payload["run_dir"] = workspace_relative(run_dir)
280
+ payload["manifest_path"] = workspace_relative(manifest_path)
281
+ return json_response(payload)
282
+
283
+
284
+ TOOL_SCHEMA = {
285
+ "name": "generate_3d_model",
286
+ "description": textwrap.dedent("""
287
+ Generate a non-precise 3D model from a local image using the Lux3D image-to-3D API.
288
+ The input is a workspace-relative image path, not image bytes or base64 text.
289
+ The tool reads the image, creates a Lux3D task, polls for completion, downloads the result, and writes a manifest.
290
+ """).strip(),
291
+ "input_schema": {
292
+ "type": "object",
293
+ "properties": {
294
+ "image_path": {
295
+ "type": "string",
296
+ "description": "Workspace-relative path to a source image file. Supported formats: .png, .jpg, .jpeg, .webp.",
297
+ },
298
+ "output_path": {
299
+ "type": "string",
300
+ "description": "Optional workspace-relative model file path or output directory. Defaults to outputs.",
301
+ },
302
+ },
303
+ "required": ["image_path"],
304
+ },
305
+ }
306
+
agent_core/utils.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+
4
+ def json_response(payload: dict) -> str:
5
+ return json.dumps(payload, ensure_ascii=False, indent=2)
6
+
7
+
8
+ def tail_text(text: str, limit: int = 4000) -> str:
9
+ if not text:
10
+ return ""
11
+ return text[-limit:]
12
+
agent_loop.py CHANGED
@@ -1,816 +1,52 @@
1
- import base64
2
- import hashlib
3
- import json
4
- import mimetypes
5
- import os
6
- import subprocess
7
- import sys
8
- import textwrap
9
- import time
10
- import urllib.error
11
- import urllib.parse
12
- import urllib.request
13
- from datetime import datetime
14
- from pathlib import Path
15
- from uuid import uuid4
16
 
17
- try:
18
- from dotenv import load_dotenv
19
- except ImportError:
20
- def load_dotenv(*args, **kwargs):
21
- return False
22
-
23
- load_dotenv(override=True)
24
-
25
- if os.getenv("ANTHROPIC_BASE_URL"):
26
- os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
27
-
28
- WORKDIR = Path.cwd()
29
- DEFAULT_CADQUERY_OUTPUT_PATH = "outputs/model.step"
30
- DEFAULT_LUX3D_OUTPUT_PATH = "outputs"
31
- STEP_SUFFIXES = (".step", ".stp")
32
- IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp")
33
- LUX3D_API_KEY_ENV = "LUX3D_API_KEY"
34
- LUX3D_CREATE_URL = "https://api.luxreal.ai/global/lux3d/generate/task/create"
35
- LUX3D_GET_URL = "https://api.luxreal.ai/global/lux3d/generate/task/get"
36
- LUX3D_POLL_INTERVAL_SECONDS = 12
37
- LUX3D_TIMEOUT_SECONDS = 600
38
-
39
- SYSTEM = f"""
40
- You are a CAD and 3D model generation agent at {WORKDIR}.
41
-
42
- You have two tools:
43
- - execute_cadquery: use it for precise CAD, dimensioned parts, parametric geometry, engineering models, and STEP/CAD output.
44
- - generate_3d_model: use it when the user provides an image path and asks for a 3D model, mesh, GLB, OBJ, or other non-precise 3D asset.
45
-
46
- For CadQuery tasks:
47
- - Write CadQuery Python code.
48
- - The code must assign the final model to a variable named result.
49
- - You may use cadquery as cq; it is pre-imported by the tool.
50
- - Call execute_cadquery with the code and optional output_path.
51
- - Each successful tool call writes into a unique run directory to avoid overwriting previous models.
52
- - If execute_cadquery returns ok=false, inspect the structured error, fix the code, and call execute_cadquery again.
53
-
54
- For image-to-3D tasks:
55
- - The user must provide a workspace-relative image path.
56
- - If the user asks for image-to-3D generation but does not provide an image path, ask for the image path. Do not guess.
57
- - Call generate_3d_model with the image_path and optional output_path.
58
- - Do not ask the user to read files, convert images to base64, call APIs, or download results manually.
59
-
60
- When a tool returns ok=true, report output_path, run_dir, and manifest_path to the user.
61
-
62
- Do not ask the user to run commands or create files manually.
63
- """.strip()
64
-
65
-
66
- def get_client():
67
- from anthropic import Anthropic
68
-
69
- return Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
70
-
71
-
72
- def safe_path(p: str) -> Path:
73
- path = (WORKDIR / p).resolve()
74
- if not path.is_relative_to(WORKDIR):
75
- raise ValueError(f"Path escapes workspace: {p}")
76
- return path
77
-
78
-
79
- def workspace_relative(path: Path) -> str:
80
- return str(path.relative_to(WORKDIR))
81
-
82
-
83
- def new_run_id() -> str:
84
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
85
- return f"{timestamp}_{uuid4().hex[:6]}"
86
-
87
-
88
- def tail_text(text: str, limit: int = 4000) -> str:
89
- if not text:
90
- return ""
91
- return text[-limit:]
92
-
93
-
94
- def allocate_run_dir(output_root: Path) -> tuple[Path, str]:
95
- for _ in range(20):
96
- run_id = new_run_id()
97
- run_dir = output_root / "runs" / run_id
98
- if not run_dir.exists():
99
- return run_dir, run_id
100
-
101
- raise RuntimeError("Failed to allocate a unique output run directory.")
102
-
103
-
104
- def resolve_run_output(requested_output_path: str) -> tuple[Path, Path, str]:
105
- requested_path = safe_path(requested_output_path)
106
- suffix = requested_path.suffix.lower()
107
-
108
- if suffix in STEP_SUFFIXES:
109
- output_root = requested_path.parent
110
- output_name = requested_path.name
111
- elif suffix:
112
- raise ValueError("Only STEP output is supported in this version. Use a .step/.stp file path or a directory path.")
113
- else:
114
- output_root = requested_path
115
- output_name = "model.step"
116
-
117
- run_dir, run_id = allocate_run_dir(output_root)
118
- return run_dir, run_dir / output_name, run_id
119
-
120
-
121
- def resolve_lux3d_run(output_path: str | None) -> tuple[Path, Path, str, str | None, str]:
122
- requested_output_path = output_path or DEFAULT_LUX3D_OUTPUT_PATH
123
- requested_path = safe_path(requested_output_path)
124
- if requested_path.suffix:
125
- output_root = requested_path.parent
126
- output_name = requested_path.name
127
- else:
128
- output_root = requested_path
129
- output_name = None
130
-
131
- run_dir, run_id = allocate_run_dir(output_root)
132
- return output_root, run_dir, run_id, output_name, requested_output_path
133
-
134
-
135
- def update_latest_link(output_root: Path, run_dir: Path) -> str | None:
136
- latest = output_root / "latest"
137
- try:
138
- if latest.is_symlink() or latest.is_file():
139
- latest.unlink()
140
- elif latest.exists():
141
- return f"Skipped latest link because {workspace_relative(latest)} already exists and is not a symlink."
142
-
143
- latest.symlink_to(Path("runs") / run_dir.name, target_is_directory=True)
144
- except OSError as exc:
145
- return f"Failed to update latest link: {exc}"
146
-
147
- return None
148
-
149
-
150
- def write_manifest(
151
- run_dir: Path,
152
- run_id: str,
153
- requested_output_path: str,
154
- output_path: Path,
155
- code: str,
156
- prompt: str | None,
157
- payload: dict,
158
- ) -> Path:
159
- manifest_path = run_dir / "manifest.json"
160
- manifest_path.parent.mkdir(parents=True, exist_ok=True)
161
- manifest = {
162
- "run_id": run_id,
163
- "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
164
- "prompt": prompt,
165
- "requested_output_path": requested_output_path,
166
- "output_path": workspace_relative(output_path),
167
- "format": output_path.suffix.lstrip(".").lower(),
168
- "code": code,
169
- "stdout": payload.get("stdout", ""),
170
- "stderr": payload.get("stderr", ""),
171
- }
172
- manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
173
- return manifest_path
174
-
175
-
176
- def write_lux3d_manifest(
177
- run_dir: Path,
178
- run_id: str,
179
- prompt: str | None,
180
- image_path: Path,
181
- requested_output_path: str,
182
- output_path: Path | None,
183
- busid: str | int | None,
184
- status: int | None,
185
- result_url: str | None,
186
- poll_count: int,
187
- error: str | None = None,
188
- ) -> Path:
189
- manifest_path = run_dir / "manifest.json"
190
- manifest_path.parent.mkdir(parents=True, exist_ok=True)
191
- manifest = {
192
- "run_id": run_id,
193
- "created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
194
- "generator": "lux3d",
195
- "prompt": prompt,
196
- "image_path": workspace_relative(image_path),
197
- "requested_output_path": requested_output_path,
198
- "output_path": workspace_relative(output_path) if output_path else None,
199
- "busid": busid,
200
- "status": status,
201
- "result_url": result_url,
202
- "poll_count": poll_count,
203
- "error": error,
204
- }
205
- manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
206
- return manifest_path
207
-
208
-
209
- def validate_image_path(image_path: str) -> Path:
210
- if not image_path or not image_path.strip():
211
- raise ValueError("image_path is required for image-to-3D generation.")
212
-
213
- path = safe_path(image_path)
214
- if not path.exists():
215
- raise FileNotFoundError(f"Image file does not exist: {image_path}")
216
- if not path.is_file():
217
- raise ValueError(f"image_path must be a file: {image_path}")
218
- if path.suffix.lower() not in IMAGE_SUFFIXES:
219
- raise ValueError(f"Unsupported image format: {path.suffix}. Use one of: {', '.join(IMAGE_SUFFIXES)}")
220
- return path
221
-
222
-
223
- def image_to_data_url(image_path: Path) -> str:
224
- mime_type = mimetypes.guess_type(str(image_path))[0]
225
- if not mime_type:
226
- mime_type = "image/jpeg" if image_path.suffix.lower() in (".jpg", ".jpeg") else f"image/{image_path.suffix.lower().lstrip('.')}"
227
- encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
228
- return f"data:{mime_type};base64,{encoded}"
229
-
230
-
231
- def parse_lux3d_api_key() -> dict:
232
- api_key = os.getenv(LUX3D_API_KEY_ENV, "").strip()
233
- if not api_key:
234
- raise ValueError(f"Missing {LUX3D_API_KEY_ENV}. Set it in your environment or .env file.")
235
-
236
- try:
237
- decoded = base64.b64decode(api_key, validate=True).decode("utf-8")
238
- except Exception as exc:
239
- raise ValueError(f"Invalid {LUX3D_API_KEY_ENV}: expected a base64-encoded API key.") from exc
240
-
241
- parts = decoded.split(":")
242
- if len(parts) != 4 or not all(parts):
243
- raise ValueError(f"Invalid {LUX3D_API_KEY_ENV}: expected decoded format version:appkey:appsecret:appuid.")
244
-
245
- version, appkey, appsecret, appuid = parts
246
- return {
247
- "version": version,
248
- "appkey": appkey,
249
- "appsecret": appsecret,
250
- "appuid": appuid,
251
- }
252
-
253
-
254
- def lux3d_query(credentials: dict, extra: dict | None = None) -> str:
255
- timestamp = str(int(time.time() * 1000))
256
- sign = hashlib.md5(
257
- (credentials["appsecret"] + credentials["appkey"] + credentials["appuid"] + timestamp).encode("utf-8")
258
- ).hexdigest()
259
- params = {
260
- "appuid": credentials["appuid"],
261
- "appkey": credentials["appkey"],
262
- "timestamp": timestamp,
263
- "sign": sign,
264
- }
265
- if extra:
266
- params.update(extra)
267
- return urllib.parse.urlencode(params)
268
-
269
-
270
- def lux3d_json_request(method: str, url: str, payload: dict | None = None, timeout: int = 30) -> dict:
271
- data = None
272
- headers = {}
273
- if payload is not None:
274
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
275
- headers["Content-Type"] = "application/json"
276
-
277
- request = urllib.request.Request(url, data=data, headers=headers, method=method)
278
- try:
279
- with urllib.request.urlopen(request, timeout=timeout) as response:
280
- raw = response.read().decode("utf-8")
281
- except urllib.error.HTTPError as exc:
282
- body = exc.read().decode("utf-8", errors="replace")
283
- raise RuntimeError(f"Lux3D API HTTP {exc.code}: {tail_text(body)}") from exc
284
- except urllib.error.URLError as exc:
285
- raise RuntimeError(f"Lux3D API request failed: {exc.reason}") from exc
286
-
287
- try:
288
- return json.loads(raw)
289
- except Exception as exc:
290
- raise RuntimeError(f"Lux3D API returned invalid JSON: {tail_text(raw)}") from exc
291
-
292
-
293
- def lux3d_create_task(credentials: dict, image_data_url: str) -> str | int:
294
- query = lux3d_query(credentials)
295
- payload = lux3d_json_request("POST", f"{LUX3D_CREATE_URL}?{query}", {"img": image_data_url})
296
- busid = payload.get("d")
297
- if not busid:
298
- raise RuntimeError(f"Lux3D create task response did not include busid: {payload}")
299
- return busid
300
-
301
-
302
- def lux3d_get_task(credentials: dict, busid: str | int) -> dict:
303
- query = lux3d_query(credentials, {"busid": busid})
304
- payload = lux3d_json_request("GET", f"{LUX3D_GET_URL}?{query}")
305
- data = payload.get("d")
306
- if not isinstance(data, dict):
307
- raise RuntimeError(f"Lux3D get task response did not include task data: {payload}")
308
- return data
309
-
310
-
311
- def infer_lux3d_output_name(result_url: str) -> str:
312
- parsed = urllib.parse.urlparse(result_url)
313
- name = Path(urllib.parse.unquote(parsed.path)).name
314
- if name and "." in name:
315
- return name
316
- return "model.glb"
317
-
318
-
319
- def download_lux3d_result(result_url: str, output_path: Path) -> None:
320
- request = urllib.request.Request(result_url, headers={"User-Agent": "aigc-3dcad/0.1"})
321
- try:
322
- with urllib.request.urlopen(request, timeout=120) as response:
323
- data = response.read()
324
- except urllib.error.HTTPError as exc:
325
- body = exc.read().decode("utf-8", errors="replace")
326
- raise RuntimeError(f"Lux3D result download HTTP {exc.code}: {tail_text(body)}") from exc
327
- except urllib.error.URLError as exc:
328
- raise RuntimeError(f"Lux3D result download failed: {exc.reason}") from exc
329
-
330
- output_path.parent.mkdir(parents=True, exist_ok=True)
331
- output_path.write_bytes(data)
332
-
333
-
334
- CADQUERY_RUNNER = r"""
335
- import contextlib
336
- import io
337
- import json
338
- import sys
339
- import traceback
340
- from pathlib import Path
341
-
342
- stdout_buffer = io.StringIO()
343
- stderr_buffer = io.StringIO()
344
-
345
-
346
- def tail(text, limit=4000):
347
- if not text:
348
- return ""
349
- return text[-limit:]
350
-
351
-
352
- def fail(stage, exc=None, error_type=None, error=None):
353
- payload = {
354
- "ok": False,
355
- "stage": stage,
356
- "error_type": error_type or (type(exc).__name__ if exc else "Error"),
357
- "error": error or (str(exc) if exc else ""),
358
- "traceback_tail": tail(traceback.format_exc() if exc else ""),
359
- "stdout": tail(stdout_buffer.getvalue()),
360
- "stderr": tail(stderr_buffer.getvalue()),
361
- }
362
- print(json.dumps(payload, ensure_ascii=False))
363
-
364
-
365
- try:
366
- request = json.loads(sys.stdin.read())
367
- code = request["code"]
368
- output_path = Path(request["output_path"])
369
- except Exception as exc:
370
- fail("input", exc)
371
- sys.exit(0)
372
-
373
- try:
374
- import cadquery as cq
375
- except Exception as exc:
376
- fail("import", exc)
377
- sys.exit(0)
378
-
379
- try:
380
- compiled = compile(code, "<cadquery_code>", "exec")
381
- except Exception as exc:
382
- fail("syntax", exc)
383
- sys.exit(0)
384
-
385
- namespace = {
386
- "__name__": "__cadquery_user_code__",
387
- "cq": cq,
388
- }
389
-
390
- try:
391
- with contextlib.redirect_stdout(stdout_buffer), contextlib.redirect_stderr(stderr_buffer):
392
- exec(compiled, namespace)
393
- except Exception as exc:
394
- fail("execution", exc)
395
- sys.exit(0)
396
-
397
- if "result" not in namespace:
398
- fail("result", error_type="MissingResult", error="CadQuery code must assign the final model to variable 'result'.")
399
- sys.exit(0)
400
-
401
- result = namespace["result"]
402
- if result is None:
403
- fail("result", error_type="InvalidResult", error="'result' is None.")
404
- sys.exit(0)
405
-
406
- exportable_types = tuple(
407
- value
408
- for name in ("Workplane", "Shape", "Assembly", "Sketch")
409
- for value in [getattr(cq, name, None)]
410
- if isinstance(value, type)
411
- )
412
-
413
- is_exportable = isinstance(result, exportable_types)
414
- if isinstance(result, (list, tuple)) and result:
415
- is_exportable = all(isinstance(item, exportable_types) for item in result)
416
-
417
- if not is_exportable:
418
- fail(
419
- "result",
420
- error_type="InvalidResultType",
421
- error=f"'result' must be a CadQuery object or a non-empty list/tuple of CadQuery objects, got {type(result).__name__}.",
422
- )
423
- sys.exit(0)
424
-
425
- try:
426
- output_path.parent.mkdir(parents=True, exist_ok=True)
427
- cq.exporters.export(result, str(output_path))
428
- except Exception as exc:
429
- fail("export", exc)
430
- sys.exit(0)
431
-
432
- payload = {
433
- "ok": True,
434
- "stage": "done",
435
- "output_path": str(output_path),
436
- "stdout": tail(stdout_buffer.getvalue()),
437
- "stderr": tail(stderr_buffer.getvalue()),
438
- }
439
- print(json.dumps(payload, ensure_ascii=False))
440
  """
441
 
442
-
443
- def _json_response(payload: dict) -> str:
444
- return json.dumps(payload, ensure_ascii=False, indent=2)
445
-
446
-
447
- def run_execute_cadquery(code: str, output_path: str = DEFAULT_CADQUERY_OUTPUT_PATH, prompt: str | None = None) -> str:
448
- try:
449
- if not code or not code.strip():
450
- return _json_response({
451
- "ok": False,
452
- "stage": "input",
453
- "error_type": "EmptyCode",
454
- "error": "execute_cadquery requires non-empty CadQuery code.",
455
- "traceback_tail": "",
456
- "stdout": "",
457
- "stderr": "",
458
- })
459
-
460
- run_dir, output, run_id = resolve_run_output(output_path)
461
- output_root = run_dir.parent.parent
462
- except Exception as exc:
463
- return _json_response({
464
- "ok": False,
465
- "stage": "input",
466
- "error_type": type(exc).__name__,
467
- "error": str(exc),
468
- "traceback_tail": "",
469
- "stdout": "",
470
- "stderr": "",
471
- })
472
-
473
- request = {
474
- "code": code,
475
- "output_path": str(output),
476
- }
477
-
478
- try:
479
- process = subprocess.run(
480
- [sys.executable, "-c", CADQUERY_RUNNER],
481
- input=json.dumps(request, ensure_ascii=False),
482
- cwd=WORKDIR,
483
- capture_output=True,
484
- text=True,
485
- timeout=120,
486
- )
487
- except subprocess.TimeoutExpired as exc:
488
- return _json_response({
489
- "ok": False,
490
- "stage": "execution",
491
- "error_type": "TimeoutExpired",
492
- "error": "CadQuery execution timed out after 120 seconds.",
493
- "traceback_tail": "",
494
- "stdout": exc.stdout[-4000:] if exc.stdout else "",
495
- "stderr": exc.stderr[-4000:] if exc.stderr else "",
496
- })
497
- except Exception as exc:
498
- return _json_response({
499
- "ok": False,
500
- "stage": "subprocess",
501
- "error_type": type(exc).__name__,
502
- "error": str(exc),
503
- "traceback_tail": "",
504
- "stdout": "",
505
- "stderr": "",
506
- })
507
-
508
- raw_output = process.stdout.strip()
509
- if not raw_output:
510
- return _json_response({
511
- "ok": False,
512
- "stage": "subprocess",
513
- "error_type": "NoRunnerOutput",
514
- "error": "CadQuery runner returned no JSON output.",
515
- "traceback_tail": "",
516
- "stdout": "",
517
- "stderr": process.stderr[-4000:],
518
- })
519
-
520
- try:
521
- payload = json.loads(raw_output.splitlines()[-1])
522
- except Exception as exc:
523
- return _json_response({
524
- "ok": False,
525
- "stage": "subprocess",
526
- "error_type": type(exc).__name__,
527
- "error": f"Failed to parse CadQuery runner output: {exc}",
528
- "traceback_tail": "",
529
- "stdout": raw_output[-4000:],
530
- "stderr": process.stderr[-4000:],
531
- })
532
-
533
- if process.returncode != 0 and payload.get("ok") is not False:
534
- payload = {
535
- "ok": False,
536
- "stage": "subprocess",
537
- "error_type": "RunnerFailed",
538
- "error": f"CadQuery runner exited with code {process.returncode}.",
539
- "traceback_tail": "",
540
- "stdout": raw_output[-4000:],
541
- "stderr": process.stderr[-4000:],
542
- }
543
-
544
- if payload.get("ok") and payload.get("output_path"):
545
- exported_path = Path(payload["output_path"])
546
- manifest_path = write_manifest(
547
- run_dir=run_dir,
548
- run_id=run_id,
549
- requested_output_path=output_path,
550
- output_path=exported_path,
551
- code=code,
552
- prompt=prompt,
553
- payload=payload,
554
- )
555
- latest_warning = update_latest_link(output_root, run_dir)
556
-
557
- payload["run_id"] = run_id
558
- payload["run_dir"] = workspace_relative(run_dir)
559
- payload["output_path"] = workspace_relative(exported_path)
560
- payload["manifest_path"] = workspace_relative(manifest_path)
561
- payload["latest_path"] = workspace_relative(output_root / "latest")
562
- if latest_warning:
563
- payload["warning"] = latest_warning
564
-
565
- return _json_response(payload)
566
-
567
-
568
- def run_generate_3d_model(
569
- image_path: str,
570
- output_path: str | None = None,
571
- prompt: str | None = None,
572
- poll_interval_seconds: int = LUX3D_POLL_INTERVAL_SECONDS,
573
- timeout_seconds: int = LUX3D_TIMEOUT_SECONDS,
574
- ) -> str:
575
- busid = None
576
- status = None
577
- result_url = None
578
- output = None
579
- poll_count = 0
580
- run_dir = None
581
- run_id = None
582
- requested_output_path = output_path or DEFAULT_LUX3D_OUTPUT_PATH
583
-
584
- try:
585
- image = validate_image_path(image_path)
586
- credentials = parse_lux3d_api_key()
587
- output_root, run_dir, run_id, requested_output_name, requested_output_path = resolve_lux3d_run(output_path)
588
- image_data_url = image_to_data_url(image)
589
- except Exception as exc:
590
- return _json_response({
591
- "ok": False,
592
- "stage": "input",
593
- "error_type": type(exc).__name__,
594
- "error": str(exc),
595
- "busid": busid,
596
- })
597
-
598
- try:
599
- busid = lux3d_create_task(credentials, image_data_url)
600
- deadline = time.time() + timeout_seconds
601
-
602
- while True:
603
- if time.time() >= deadline:
604
- raise TimeoutError(f"Lux3D task timed out after {timeout_seconds} seconds.")
605
-
606
- time.sleep(poll_interval_seconds)
607
- poll_count += 1
608
- task = lux3d_get_task(credentials, busid)
609
- raw_status = task.get("status")
610
- status = int(raw_status) if raw_status is not None else None
611
-
612
- if status in (0, 1):
613
- continue
614
-
615
- if status == 3:
616
- outputs = task.get("outputs") or []
617
- result_url = next(
618
- (item.get("content") for item in outputs if isinstance(item, dict) and item.get("content")),
619
- None,
620
- )
621
- if not result_url:
622
- raise RuntimeError(f"Lux3D task succeeded but did not include a result URL: {task}")
623
-
624
- output_name = requested_output_name or infer_lux3d_output_name(result_url)
625
- output = run_dir / output_name
626
- download_lux3d_result(result_url, output)
627
- manifest_path = write_lux3d_manifest(
628
- run_dir=run_dir,
629
- run_id=run_id,
630
- prompt=prompt,
631
- image_path=image,
632
- requested_output_path=requested_output_path,
633
- output_path=output,
634
- busid=busid,
635
- status=status,
636
- result_url=result_url,
637
- poll_count=poll_count,
638
- )
639
- latest_warning = update_latest_link(output_root, run_dir)
640
- payload = {
641
- "ok": True,
642
- "stage": "done",
643
- "run_id": run_id,
644
- "run_dir": workspace_relative(run_dir),
645
- "output_path": workspace_relative(output),
646
- "manifest_path": workspace_relative(manifest_path),
647
- "latest_path": workspace_relative(output_root / "latest"),
648
- "busid": busid,
649
- "status": status,
650
- "result_url": result_url,
651
- "poll_count": poll_count,
652
- }
653
- if latest_warning:
654
- payload["warning"] = latest_warning
655
- return _json_response(payload)
656
-
657
- if status == 4:
658
- raise RuntimeError(f"Lux3D task failed: {task}")
659
-
660
- raise RuntimeError(f"Lux3D task returned unknown status {raw_status}: {task}")
661
- except Exception as exc:
662
- manifest_path = None
663
- if run_dir and run_id:
664
- manifest_path = write_lux3d_manifest(
665
- run_dir=run_dir,
666
- run_id=run_id,
667
- prompt=prompt,
668
- image_path=image,
669
- requested_output_path=requested_output_path,
670
- output_path=output,
671
- busid=busid,
672
- status=status,
673
- result_url=result_url,
674
- poll_count=poll_count,
675
- error=str(exc),
676
- )
677
-
678
- payload = {
679
- "ok": False,
680
- "stage": "lux3d",
681
- "error_type": type(exc).__name__,
682
- "error": str(exc),
683
- "busid": busid,
684
- "status": status,
685
- "result_url": result_url,
686
- "poll_count": poll_count,
687
- }
688
- if manifest_path:
689
- payload["run_id"] = run_id
690
- payload["run_dir"] = workspace_relative(run_dir)
691
- payload["manifest_path"] = workspace_relative(manifest_path)
692
- return _json_response(payload)
693
-
694
-
695
- # -- The dispatch map: {tool_name: handler} --
696
- TOOL_HANDLERS = {
697
- "execute_cadquery": lambda **kw: run_execute_cadquery(
698
- kw["code"],
699
- kw.get("output_path", DEFAULT_CADQUERY_OUTPUT_PATH),
700
- ),
701
- "generate_3d_model": lambda **kw: run_generate_3d_model(
702
- kw["image_path"],
703
- kw.get("output_path"),
704
- ),
705
- }
706
-
707
- TOOLS = [
708
- {
709
- "name": "execute_cadquery",
710
- "description": textwrap.dedent("""
711
- Execute CadQuery Python code and export the result as a STEP file.
712
- The code must assign the final CadQuery model to a variable named result.
713
- cadquery is pre-imported as cq.
714
- output_path is optional. If it is a .step/.stp file path, the file name is used inside a unique run directory.
715
- If it is a directory path, model.step is written inside a unique run directory under that root.
716
- """).strip(),
717
- "input_schema": {
718
- "type": "object",
719
- "properties": {
720
- "code": {
721
- "type": "string",
722
- "description": "CadQuery Python code. It must assign the final model to variable 'result'.",
723
- },
724
- "output_path": {
725
- "type": "string",
726
- "description": "Optional workspace-relative STEP file path or output directory. Defaults to outputs/model.step.",
727
- },
728
- },
729
- "required": ["code"],
730
- },
731
- },
732
- {
733
- "name": "generate_3d_model",
734
- "description": textwrap.dedent("""
735
- Generate a non-precise 3D model from a local image using the Lux3D image-to-3D API.
736
- The input is a workspace-relative image path, not image bytes or base64 text.
737
- The tool reads the image, creates a Lux3D task, polls for completion, downloads the result, and writes a manifest.
738
- """).strip(),
739
- "input_schema": {
740
- "type": "object",
741
- "properties": {
742
- "image_path": {
743
- "type": "string",
744
- "description": "Workspace-relative path to a source image file. Supported formats: .png, .jpg, .jpeg, .webp.",
745
- },
746
- "output_path": {
747
- "type": "string",
748
- "description": "Optional workspace-relative model file path or output directory. Defaults to outputs.",
749
- },
750
- },
751
- "required": ["image_path"],
752
- },
753
- },
754
- ]
755
-
756
-
757
- def latest_user_prompt(messages: list) -> str | None:
758
- for message in reversed(messages):
759
- if message.get("role") == "user" and isinstance(message.get("content"), str):
760
- return message["content"]
761
- return None
762
-
763
-
764
- def agent_loop(messages: list):
765
- client = get_client()
766
- model = os.environ["MODEL_ID"]
767
-
768
- while True:
769
- response = client.messages.create(
770
- model=model, system=SYSTEM, messages=messages,
771
- tools=TOOLS, max_tokens=8000,
772
- )
773
- messages.append({"role": "assistant", "content": response.content})
774
- if response.stop_reason != "tool_use":
775
- return
776
- results = []
777
- prompt = latest_user_prompt(messages)
778
- for block in response.content:
779
- if block.type == "tool_use":
780
- if block.name == "execute_cadquery":
781
- output = run_execute_cadquery(
782
- code=block.input["code"],
783
- output_path=block.input.get("output_path", DEFAULT_CADQUERY_OUTPUT_PATH),
784
- prompt=prompt,
785
- )
786
- elif block.name == "generate_3d_model":
787
- output = run_generate_3d_model(
788
- image_path=block.input["image_path"],
789
- output_path=block.input.get("output_path"),
790
- prompt=prompt,
791
- )
792
- else:
793
- handler = TOOL_HANDLERS.get(block.name)
794
- output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
795
- print(f"> {block.name}: {output[:200]}")
796
- results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
797
- messages.append({"role": "user", "content": results})
798
 
799
 
800
  if __name__ == "__main__":
801
- history = []
802
- while True:
803
- try:
804
- query = input("\033[36mcad agent >> \033[0m")
805
- except (EOFError, KeyboardInterrupt):
806
- break
807
- if query.strip().lower() in ("q", "exit", ""):
808
- break
809
- history.append({"role": "user", "content": query})
810
- agent_loop(history)
811
- response_content = history[-1]["content"]
812
- if isinstance(response_content, list):
813
- for block in response_content:
814
- if hasattr(block, "text"):
815
- print(block.text)
816
- print()
 
1
+ """Backward-compatible entrypoint for the terminal agent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ New code should import from agent_core.* modules directly. This file remains so
4
+ existing commands like `python agent_loop.py` keep working.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
+ from agent_core.agent import TOOL_HANDLERS, TOOLS, agent_loop, latest_user_prompt
8
+ from agent_core.config import (
9
+ DEFAULT_CADQUERY_OUTPUT_PATH,
10
+ DEFAULT_LUX3D_OUTPUT_PATH,
11
+ IMAGE_SUFFIXES,
12
+ LUX3D_API_KEY_ENV,
13
+ LUX3D_CREATE_URL,
14
+ LUX3D_GET_URL,
15
+ LUX3D_POLL_INTERVAL_SECONDS,
16
+ LUX3D_TIMEOUT_SECONDS,
17
+ STEP_SUFFIXES,
18
+ WORKDIR,
19
+ get_client,
20
+ )
21
+ from agent_core.outputs import (
22
+ allocate_run_dir,
23
+ new_run_id,
24
+ resolve_lux3d_run,
25
+ resolve_run_output,
26
+ safe_path,
27
+ update_latest_link,
28
+ workspace_relative,
29
+ write_lux3d_manifest,
30
+ write_manifest,
31
+ )
32
+ from agent_core.prompts import SYSTEM
33
+ from agent_core.tools.cadquery_tool import CADQUERY_RUNNER, run_execute_cadquery
34
+ from agent_core.tools.lux3d_tool import (
35
+ download_lux3d_result,
36
+ image_to_data_url,
37
+ infer_lux3d_output_name,
38
+ lux3d_create_task,
39
+ lux3d_get_task,
40
+ lux3d_json_request,
41
+ lux3d_query,
42
+ parse_lux3d_api_key,
43
+ run_generate_3d_model,
44
+ validate_image_path,
45
+ )
46
+ from agent_core.utils import json_response as _json_response
47
+ from agent_core.utils import tail_text
48
+ from cli import main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  if __name__ == "__main__":
52
+ main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cli.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agent_core.agent import agent_loop
2
+
3
+
4
+ def main():
5
+ history = []
6
+ while True:
7
+ try:
8
+ query = input("\033[36mcad agent >> \033[0m")
9
+ except (EOFError, KeyboardInterrupt):
10
+ break
11
+ if query.strip().lower() in ("q", "exit", ""):
12
+ break
13
+
14
+ history.append({"role": "user", "content": query})
15
+ agent_loop(history)
16
+ response_content = history[-1]["content"]
17
+ if isinstance(response_content, list):
18
+ for block in response_content:
19
+ if hasattr(block, "text"):
20
+ print(block.text)
21
+ print()
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
26
+