@capagents/forgeqe 1.3.2 → 2.0.0

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.
Files changed (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,356 @@
1
+ """
2
+ ``python -m forgeqe_desktop serve``: one request as a JSON line on stdin, events as JSON lines on stdout, like the
3
+ browser engine. A later ``stop`` line on stdin stops a replay between steps, or ends a recording.
4
+
5
+ Request: ``mode`` (``replay``, ``explore`` or ``record``), ``out_dir``, ``desktop`` (``apps``, ``secrets``,
6
+ ``ufo_dir``), ``llm`` and ``profile_name`` (the llms.json profile, for healing and explore), and per mode: ``workflow``,
7
+ ``variables``, ``heal``, ``screenshots``, ``update_baselines`` (replay); ``goal``, ``app``, ``name`` (explore); ``app``,
8
+ ``name`` (record: the tester does the steps by hand).
9
+
10
+ Events: ``log``, ``step`` (one per replayed step), ``recorded`` (one per step recorded by hand), ``call`` (one model
11
+ call's time and tokens, never its text), and ``done`` with the result. ``run.json`` in ``out_dir`` holds the same result.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import threading
19
+ import time
20
+ from pathlib import Path
21
+ from typing import IO, Any, Callable, Dict, List, Mapping, Optional
22
+
23
+ from .apps import load_apps, resolve_app
24
+ from .workflow import FORMAT, WorkflowError, load, save, validate
25
+
26
+
27
+ class Events:
28
+ def __init__(self, out: IO[str]) -> None:
29
+ self.out = out
30
+ self.lock = threading.Lock()
31
+
32
+ def emit(self, event: Dict[str, Any]) -> None:
33
+ with self.lock:
34
+ self.out.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
35
+ self.out.flush()
36
+
37
+ def log(self, text: str, level: str = "info") -> None:
38
+ self.emit({"type": "log", "level": level, "text": text})
39
+
40
+
41
+ def _secret_names(desktop: Mapping[str, Any]) -> List[str]:
42
+ names = desktop.get("secrets") or []
43
+ if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
44
+ raise WorkflowError("desktop.secrets must be a list of environment variable names")
45
+ return names
46
+
47
+
48
+ def _out_dir(req: Mapping[str, Any]) -> Path:
49
+ raw = req.get("out_dir")
50
+ if not isinstance(raw, str) or not raw:
51
+ raise WorkflowError("out_dir is required")
52
+ out = Path(raw)
53
+ out.mkdir(parents=True, exist_ok=True)
54
+ return out
55
+
56
+
57
+ def _model(req: Mapping[str, Any], events: Events, model_factory: Optional[Callable[..., Any]]) -> Any:
58
+ from .models import DesktopModel
59
+
60
+ profile = req.get("llm")
61
+ if not isinstance(profile, dict):
62
+ raise WorkflowError("this mode needs an llms.json profile")
63
+ on_call = lambda c: events.emit({"type": "call", **c}) # noqa: E731
64
+ return (model_factory or DesktopModel)(profile, on_call=on_call)
65
+
66
+
67
+ def _step_fields(res: Any) -> Dict[str, Any]:
68
+ out = {"index": res.index, "op": res.op, "ok": res.ok, "ms": res.ms, "error": res.error, "healed_target": res.healed_target}
69
+ if res.attempts > 1:
70
+ out["attempts"] = res.attempts
71
+ if res.dismissed:
72
+ out["dismissed"] = list(res.dismissed)
73
+ if res.images:
74
+ out["images"] = dict(res.images)
75
+ if res.note:
76
+ out["note"] = res.note
77
+ return out
78
+
79
+
80
+ def _check_desktop(driver: Any) -> None:
81
+ """Fail before the app starts when this Windows session cannot take input (a service, a locked screen)."""
82
+ if sys.platform != "win32" or not getattr(driver, "needs_desktop", False):
83
+ return
84
+ from .session import desktop_problem
85
+
86
+ problem = desktop_problem()
87
+ if problem:
88
+ raise WorkflowError(f"This Windows session cannot be driven: {problem}")
89
+
90
+
91
+ def run_replay(req: Mapping[str, Any], events: Events, driver: Any, stop: threading.Event, model_factory: Optional[Callable[..., Any]] = None) -> Dict[str, Any]:
92
+ from .heal import make_heal
93
+ from .models import takes_images
94
+ from .replay import replay
95
+
96
+ out = _out_dir(req)
97
+ desktop = req.get("desktop") or {}
98
+ workflow_file = str(req.get("workflow") or "")
99
+ wf = load(workflow_file)
100
+ heal = make_heal(_model(req, events, model_factory), driver, log=events.log, images=takes_images(req.get("llm") or {})) if req.get("heal") else None
101
+ shots = bool(req.get("screenshots", True))
102
+ _check_desktop(driver)
103
+ from .visual import image_checker
104
+
105
+ compare_image = image_checker(workflow_file, out, update=bool(req.get("update_baselines")))
106
+ (out / "steps").mkdir(exist_ok=True)
107
+
108
+ def on_step(res: Any) -> None:
109
+ shot = None
110
+ grab = getattr(driver, "screenshot_png", None)
111
+ if shots and grab is not None and res.op not in ("close",):
112
+ png = grab()
113
+ if png:
114
+ shot = f"steps/{res.index:03d}.png"
115
+ (out / shot).write_bytes(png)
116
+ events.emit({"type": "step", **_step_fields(res), "screenshot": shot})
117
+
118
+ result = replay(
119
+ wf,
120
+ driver,
121
+ load_apps({"desktop": desktop}),
122
+ variables=req.get("variables") or {},
123
+ allowed_secrets=_secret_names(desktop),
124
+ heal=heal,
125
+ on_step=on_step,
126
+ should_stop=stop.is_set,
127
+ compare_image=compare_image,
128
+ )
129
+ steps = []
130
+ for s in result.steps:
131
+ shot = f"steps/{s.index:03d}.png"
132
+ steps.append({**_step_fields(s), "screenshot": shot if (out / shot).is_file() else None, "target": wf["steps"][s.index - 1].get("target")})
133
+ return {
134
+ "mode": "replay",
135
+ "ok": result.ok,
136
+ "stopped": result.stopped,
137
+ "name": wf["name"],
138
+ "app": wf["app"],
139
+ "workflow": workflow_file,
140
+ "total": len(wf["steps"]),
141
+ "steps": steps,
142
+ "heals": [{"index": s.index, "old": wf["steps"][s.index - 1].get("target"), "new": s.healed_target} for s in result.healed],
143
+ }
144
+
145
+
146
+ def _ufo_verdict(out: Path) -> Optional[bool]:
147
+ """UFO's own evaluation of the run (``evaluation.log``), when it wrote one."""
148
+ path = out / "ufo" / "evaluation.log"
149
+ if not path.is_file():
150
+ return None
151
+ for line in reversed(path.read_text(encoding="utf-8", errors="replace").splitlines()):
152
+ try:
153
+ doc = json.loads(line)
154
+ except ValueError:
155
+ continue
156
+ complete = str(doc.get("complete", "")).strip().lower() if isinstance(doc, dict) else ""
157
+ if complete in ("yes", "no"):
158
+ return complete == "yes"
159
+ return None
160
+
161
+
162
+ def run_explore(
163
+ req: Mapping[str, Any],
164
+ events: Events,
165
+ driver: Any,
166
+ runner: Optional[Callable[..., List[Dict[str, Any]]]] = None,
167
+ model_factory: Optional[Callable[..., Any]] = None,
168
+ ) -> Dict[str, Any]:
169
+ from .record import steps_from_trace
170
+ from .models import require_vision
171
+
172
+ out = _out_dir(req)
173
+ desktop = req.get("desktop") or {}
174
+ goal = str(req.get("goal") or "").strip()
175
+ if not goal:
176
+ raise WorkflowError("explore needs a goal")
177
+ app = str(req.get("app") or "")
178
+ spec = resolve_app(app, load_apps({"desktop": desktop}))
179
+ profile = req.get("llm") or {}
180
+ profile_name = str(req.get("profile_name") or "profile")
181
+ require_vision(profile_name, profile)
182
+ name = str(req.get("name") or goal)[:120]
183
+ ufo_dir = Path(str(desktop.get("ufo_dir") or ""))
184
+ secrets = {n: os.environ[n] for n in _secret_names(desktop) if os.environ.get(n)}
185
+
186
+ if runner is None:
187
+ from .ufo_runner import run_ufo as runner
188
+
189
+ _check_desktop(driver)
190
+ events.log(f"Starting {app} and handing the goal to UFO.")
191
+ driver.launch(spec)
192
+ started = time.time()
193
+ trace: List[Dict[str, Any]] = []
194
+ ufo_error = ""
195
+ try:
196
+ runner(
197
+ ufo_dir,
198
+ goal,
199
+ task=f"forgeqe-{out.name}",
200
+ profile_name=profile_name,
201
+ profile=profile,
202
+ out_dir=out,
203
+ on_call=lambda c: events.emit({"type": "call", **c}),
204
+ log=events.log,
205
+ trace=trace,
206
+ **({"model": model_factory(profile, on_call=lambda c: events.emit({"type": "call", **c}))} if model_factory else {}),
207
+ )
208
+ except Exception as e:
209
+ # UFO stopping with an error (its evaluation, a parse failure) still leaves the actions it took in the trace.
210
+ ufo_error = f"UFO stopped with an error: {type(e).__name__}: {e}"[:2000]
211
+ events.log(ufo_error, "error")
212
+ workflow, skipped = steps_from_trace(trace, app=app, name=name, secrets=secrets)
213
+ recorded = len(workflow["steps"]) - 1
214
+ checks: List[Dict[str, Any]] = []
215
+ values: Dict[str, str] = {}
216
+ try:
217
+ # Checks are read from the app as UFO left it, before it closes: they make the replay test the outcome.
218
+ if recorded and not ufo_error and req.get("checks", True):
219
+ from .checks import read_outcome
220
+
221
+ checks, values = read_outcome(_model(req, events, model_factory), driver, goal, secrets=secrets, log=events.log)
222
+ if checks:
223
+ workflow = validate({**workflow, "steps": [*workflow["steps"], *checks]})
224
+ events.log(f"Added {len(checks)} check{'' if len(checks) == 1 else 's'} of the outcome.")
225
+ else:
226
+ events.log("No control shows text that proves the goal; the workflow has no checks. Add expect steps by hand.", "warn")
227
+ except Exception as e:
228
+ events.log(f"Checks were not added: {e}", "warn")
229
+ finally:
230
+ try:
231
+ driver.close()
232
+ except Exception:
233
+ pass
234
+ (out / "trace.json").write_text(json.dumps(trace, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
235
+ verdict = _ufo_verdict(out)
236
+ workflow_file = out / "workflow.yaml"
237
+ if recorded:
238
+ save(workflow, workflow_file)
239
+ ok = recorded > 0 and verdict is not False and not ufo_error
240
+ return {
241
+ "mode": "explore",
242
+ "ok": ok,
243
+ "goal": goal,
244
+ "app": app,
245
+ "name": name,
246
+ "seconds": int(time.time() - started),
247
+ "recorded": recorded,
248
+ "checks": len(checks),
249
+ "values": values,
250
+ "skipped": skipped,
251
+ "refused": [t for t in trace if t.get("refused")],
252
+ "ufo_complete": verdict,
253
+ "workflow": str(workflow_file) if recorded else None,
254
+ "error": ufo_error + (f" The workflow holds the {recorded} steps recorded before that." if ufo_error and recorded else "")
255
+ if ufo_error
256
+ else ("" if recorded else "UFO took no action that could be recorded as a step"),
257
+ }
258
+
259
+
260
+ def run_record(req: Mapping[str, Any], events: Events, driver: Any, stop: threading.Event, recorder_loop: Optional[Callable[..., str]] = None) -> Dict[str, Any]:
261
+ """Recording by hand: the tester does the steps, their clicks and keys become workflow.yaml. No model."""
262
+ from .handrecord import HandRecorder
263
+
264
+ out = _out_dir(req)
265
+ desktop = req.get("desktop") or {}
266
+ app = str(req.get("app") or "")
267
+ spec = resolve_app(app, load_apps({"desktop": desktop}))
268
+ name = str(req.get("name") or f"Recorded in {app}")[:120]
269
+ names = _secret_names(desktop)
270
+ secrets = {n: os.environ[n] for n in names if os.environ.get(n)}
271
+ if recorder_loop is None:
272
+ from .handhooks import record_by_hand as recorder_loop
273
+ _check_desktop(driver)
274
+ recorder = HandRecorder(app=app, name=name, secrets=secrets, secret_names=names)
275
+ started = time.time()
276
+ reason = ""
277
+ error = ""
278
+ try:
279
+ reason = recorder_loop(driver, spec, recorder, stop, log=events.log, on_change=lambda e: events.emit({"type": "recorded", **e}))
280
+ except Exception as e:
281
+ error = f"Recording stopped with an error: {type(e).__name__}: {e}"[:2000]
282
+ events.log(error, "error")
283
+ finally:
284
+ # The text of a field still being typed into is read before the app closes.
285
+ workflow, skipped = recorder.finish()
286
+ try:
287
+ driver.close()
288
+ except Exception:
289
+ pass
290
+ recorded = len(workflow["steps"]) - 1
291
+ workflow_file = out / "workflow.yaml"
292
+ if recorded:
293
+ save(workflow, workflow_file)
294
+ checks = sum(1 for s in workflow["steps"] if s["op"] == "expect")
295
+ return {
296
+ "mode": "record",
297
+ "ok": recorded > 0 and not error,
298
+ "app": app,
299
+ "name": name,
300
+ "seconds": int(time.time() - started),
301
+ "recorded": recorded,
302
+ "checks": checks,
303
+ "stopped_by": reason or None,
304
+ "secrets": list(recorder.secrets_used),
305
+ "skipped": skipped,
306
+ "workflow": str(workflow_file) if recorded else None,
307
+ "steps": [{"index": i, "op": s["op"], "ok": True, "ms": 0, "target": s.get("target")} for i, s in enumerate(workflow["steps"], 1)],
308
+ "error": error or ("" if recorded else "nothing was recorded: no click or typing in the app's windows"),
309
+ }
310
+
311
+
312
+ def _watch_stop(stream: IO[str], stop: threading.Event) -> None:
313
+ for line in stream:
314
+ if line.strip() == "stop":
315
+ stop.set()
316
+ return
317
+
318
+
319
+ def serve(
320
+ stdin: IO[str] = sys.stdin,
321
+ stdout: IO[str] = sys.stdout,
322
+ driver_factory: Optional[Callable[[], Any]] = None,
323
+ runner: Optional[Callable[..., List[Dict[str, Any]]]] = None,
324
+ model_factory: Optional[Callable[..., Any]] = None,
325
+ recorder_loop: Optional[Callable[..., str]] = None,
326
+ ) -> int:
327
+ events = Events(stdout)
328
+ stop = threading.Event()
329
+ result: Dict[str, Any]
330
+ out: Optional[Path] = None
331
+ try:
332
+ line = stdin.readline()
333
+ req = json.loads(line or "{}")
334
+ if not isinstance(req, dict):
335
+ raise WorkflowError("the request must be a JSON object")
336
+ threading.Thread(target=_watch_stop, args=(stdin, stop), daemon=True).start()
337
+ out = _out_dir(req)
338
+ if driver_factory is None:
339
+ from .pywinauto_driver import PywinautoDriver as driver_factory
340
+ driver = driver_factory()
341
+ mode = req.get("mode")
342
+ if mode == "replay":
343
+ result = run_replay(req, events, driver, stop, model_factory)
344
+ elif mode == "explore":
345
+ result = run_explore(req, events, driver, runner, model_factory)
346
+ elif mode == "record":
347
+ result = run_record(req, events, driver, stop, recorder_loop)
348
+ else:
349
+ raise WorkflowError(f"mode must be replay, explore or record, not {mode!r}")
350
+ except Exception as e:
351
+ result = {"ok": False, "error": f"{type(e).__name__}: {e}" if not isinstance(e, (WorkflowError, ValueError)) else str(e)}
352
+ result.setdefault("format", FORMAT)
353
+ if out is not None:
354
+ (out / "run.json").write_text(json.dumps(result, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
355
+ events.emit({"type": "done", "result": result})
356
+ return 0 if result.get("ok") else 1
@@ -0,0 +1,60 @@
1
+ """
2
+ Whether this Windows session can be driven: replay and explore move the mouse and type into windows, which needs an
3
+ interactive desktop that is unlocked. A Windows service (session 0), a locked screen, or a disconnected remote desktop
4
+ session has none, and every step would time out; the run says why instead, before it starts the app.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import Any, Dict, Optional
10
+
11
+ DESKTOP_SWITCHDESKTOP = 0x0100
12
+
13
+
14
+ def desktop_state() -> Dict[str, Any]:
15
+ """``{"interactive", "unlocked", "session", "width", "height", "problem"}`` for this process's session."""
16
+ if sys.platform != "win32":
17
+ return {"interactive": False, "unlocked": False, "session": None, "width": 0, "height": 0, "problem": "not Windows"}
18
+ import ctypes
19
+ from ctypes import wintypes
20
+
21
+ kernel32 = ctypes.windll.kernel32
22
+ user32 = ctypes.windll.user32
23
+ session = wintypes.DWORD(0)
24
+ if not kernel32.ProcessIdToSessionId(kernel32.GetCurrentProcessId(), ctypes.byref(session)):
25
+ session.value = 0xFFFFFFFF
26
+ width, height = int(user32.GetSystemMetrics(0)), int(user32.GetSystemMetrics(1))
27
+ # The input desktop is the one that gets the keyboard and mouse; while the screen is locked it is the secure
28
+ # desktop, which this process may not open or switch to.
29
+ user32.OpenInputDesktop.restype = wintypes.HANDLE
30
+ handle = user32.OpenInputDesktop(0, False, DESKTOP_SWITCHDESKTOP)
31
+ unlocked = bool(handle) and bool(user32.SwitchDesktop(handle))
32
+ if handle:
33
+ user32.CloseDesktop(handle)
34
+ state: Dict[str, Any] = {"interactive": session.value != 0, "unlocked": unlocked, "session": int(session.value), "width": width, "height": height}
35
+ state["problem"] = problem_of(state)
36
+ return state
37
+
38
+
39
+ def problem_of(state: Dict[str, Any]) -> Optional[str]:
40
+ if state.get("session") == 0:
41
+ return (
42
+ "this runs in session 0 (a Windows service), which has no desktop to click in. Run the CI runner or the "
43
+ "scheduled task in a logged-in user's session (docs/DESKTOP-CI.md)."
44
+ )
45
+ if not state.get("unlocked"):
46
+ return (
47
+ "the screen is locked, or this remote desktop session is disconnected, so no window takes input. Keep the "
48
+ "test machine's session logged in and unlocked (docs/DESKTOP-CI.md)."
49
+ )
50
+ if not state.get("width") or not state.get("height"):
51
+ return "this session has no screen (width or height is 0)."
52
+ return None
53
+
54
+
55
+ def desktop_problem() -> Optional[str]:
56
+ """Why this session cannot be driven, or None; never raises."""
57
+ try:
58
+ return desktop_state().get("problem")
59
+ except Exception:
60
+ return None
@@ -0,0 +1,86 @@
1
+ """
2
+ UFO² behind one adapter: its model calls go to the llms.json profile, and its config never holds a key.
3
+
4
+ UFO picks a model client by each agent's ``API_TYPE`` through ``ufo.llm.base.BaseService.get_service``. ``install``
5
+ makes the extra type ``forgeqe`` return ``ForgeQEService``; every other type still reaches UFO's own clients. UFO is
6
+ pinned to an exact version and never edited; if a new version changes ``get_service``, ``install`` raises rather than
7
+ running with UFO's own model config.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
12
+
13
+ from .models import DesktopModel, require_vision
14
+
15
+ API_TYPE = "forgeqe"
16
+ AGENTS = ("HOST_AGENT", "APP_AGENT", "BACKUP_AGENT", "EVALUATION_AGENT")
17
+
18
+
19
+ def agents_config(profile_name: str, profile: Mapping[str, Any]) -> Dict[str, Dict[str, Any]]:
20
+ """UFO's per-agent model blocks for a profile: the model's name only, no endpoint and no key."""
21
+ require_vision(profile_name, profile)
22
+ model = str(profile.get("deployment") or profile.get("model") or profile_name)
23
+ block = {
24
+ "VISUAL_MODE": True,
25
+ "REASONING_MODEL": False,
26
+ "API_TYPE": API_TYPE,
27
+ "API_BASE": "",
28
+ "API_KEY": "",
29
+ "API_VERSION": "",
30
+ "API_MODEL": model,
31
+ "FORGEQE_PROFILE": profile_name,
32
+ }
33
+ return {agent: dict(block) for agent in AGENTS}
34
+
35
+
36
+ class ForgeQEService:
37
+ """UFO's model-client interface (``chat_completion``) on a ForgeQE ``DesktopModel``."""
38
+
39
+ def __init__(self, model: DesktopModel) -> None:
40
+ self.model = model
41
+
42
+ def chat_completion(
43
+ self,
44
+ messages: List[Mapping[str, Any]],
45
+ n: int = 1,
46
+ stream: bool = False,
47
+ temperature: Optional[float] = None,
48
+ max_tokens: Optional[int] = None,
49
+ top_p: Optional[float] = None,
50
+ **kwargs: Any,
51
+ ) -> Tuple[List[str], Optional[float]]:
52
+ # Cost is counted from tokens by ForgeQE's own ledger (on_call), so UFO's cost estimate is 0.
53
+ return [self.model.complete(messages, max_tokens=max_tokens) for _ in range(max(1, int(n or 1)))], 0.0
54
+
55
+
56
+ def install(
57
+ profile_name: str,
58
+ profile: Mapping[str, Any],
59
+ on_call: Optional[Callable[[Dict[str, Any]], None]] = None,
60
+ base_service: Any = None,
61
+ model: Optional[DesktopModel] = None,
62
+ ) -> Callable[[], None]:
63
+ """
64
+ Route UFO's ``forgeqe`` API type to ``profile``. Returns a function that puts UFO's own ``get_service`` back.
65
+ ``base_service`` is UFO's ``BaseService`` (imported when not given).
66
+ """
67
+ require_vision(profile_name, profile)
68
+ if base_service is None:
69
+ from ufo.llm.base import BaseService as base_service # type: ignore[no-redef]
70
+
71
+ original = getattr(base_service, "get_service", None)
72
+ if original is None or not callable(original):
73
+ raise RuntimeError("this UFO version has no BaseService.get_service; the desktop bridge needs updating")
74
+ service = ForgeQEService(model or DesktopModel(profile, on_call=on_call))
75
+
76
+ def get_service(name: Any, *args: Any, **kwargs: Any) -> Any:
77
+ if str(name).strip().lower() == API_TYPE:
78
+ return service
79
+ return original(name, *args, **kwargs)
80
+
81
+ base_service.get_service = staticmethod(get_service)
82
+
83
+ def restore() -> None:
84
+ base_service.get_service = staticmethod(original)
85
+
86
+ return restore