@capagents/forgeqe 1.3.2 → 2.0.1
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.
- package/HOW_TO_USE.md +373 -9
- package/README.md +55 -4
- package/docs/ARCHITECTURE.md +62 -1
- package/docs/CONTAINERS.md +227 -0
- package/docs/COST_ARCHITECTURE.md +32 -0
- package/docs/DESKTOP-CI.md +87 -0
- package/docs/DESKTOP.md +387 -0
- package/docs/MOBILE.md +228 -0
- package/docs/feature-test-prompts.md +25 -0
- package/docs/security-data-flow.html +440 -3
- package/docs/user-guide.html +797 -415
- package/env.example.yaml +67 -0
- package/forgeqe.example.yaml +56 -1
- package/llms.example.json +7 -0
- package/package.json +4 -1
- package/prompts/brain.md +31 -0
- package/prompts/find-tests.md +1 -0
- package/py/forgeqe_desktop/__init__.py +1 -0
- package/py/forgeqe_desktop/__main__.py +3 -0
- package/py/forgeqe_desktop/apps.py +80 -0
- package/py/forgeqe_desktop/checks.py +168 -0
- package/py/forgeqe_desktop/cli.py +90 -0
- package/py/forgeqe_desktop/handhooks.py +293 -0
- package/py/forgeqe_desktop/handrecord.py +239 -0
- package/py/forgeqe_desktop/heal.py +107 -0
- package/py/forgeqe_desktop/models.py +208 -0
- package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
- package/py/forgeqe_desktop/record.py +150 -0
- package/py/forgeqe_desktop/replay.py +345 -0
- package/py/forgeqe_desktop/service.py +356 -0
- package/py/forgeqe_desktop/session.py +60 -0
- package/py/forgeqe_desktop/ufo_bridge.py +86 -0
- package/py/forgeqe_desktop/ufo_runner.py +305 -0
- package/py/forgeqe_desktop/visual.py +112 -0
- package/py/forgeqe_desktop/workflow.py +338 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_mobile/__init__.py +1 -0
- package/py/forgeqe_mobile/__main__.py +3 -0
- package/py/forgeqe_mobile/appium_driver.py +342 -0
- package/py/forgeqe_mobile/apps.py +84 -0
- package/py/forgeqe_mobile/cli.py +70 -0
- package/py/forgeqe_mobile/explore.py +203 -0
- package/py/forgeqe_mobile/hyperexecute.py +261 -0
- package/py/forgeqe_mobile/maestro.py +321 -0
- package/py/forgeqe_mobile/service.py +332 -0
- package/skills/testcontainers/SKILL.md +42 -0
- package/src/agents.ts +11 -2
- package/src/api/plan.ts +2 -0
- package/src/api/write.ts +2 -0
- package/src/auth/copilot.ts +130 -0
- package/src/auth/signin.ts +54 -1
- package/src/brain/index.ts +26 -4
- package/src/capabilities/guide.ts +2 -0
- package/src/capabilities/registry.ts +110 -2
- package/src/capabilities/status.ts +15 -0
- package/src/cases/rca.ts +2 -0
- package/src/childEnv.ts +4 -1
- package/src/cli/args.ts +3 -2
- package/src/cli/help.ts +24 -3
- package/src/cli/index.ts +47 -3
- package/src/code/find.ts +26 -0
- package/src/code/loop.ts +4 -1
- package/src/code/prompt.ts +2 -1
- package/src/code/prreview.ts +2 -0
- package/src/code/review.ts +6 -2
- package/src/code/runtime.ts +34 -3
- package/src/commands.ts +6 -1
- package/src/config.ts +159 -3
- package/src/containers/command.ts +68 -0
- package/src/containers/compose.ts +173 -0
- package/src/containers/config.ts +283 -0
- package/src/containers/mock.ts +156 -0
- package/src/containers/run.ts +362 -0
- package/src/containers/tests.ts +80 -0
- package/src/context.ts +33 -6
- package/src/copilot.ts +155 -0
- package/src/desktop/bridge.ts +229 -0
- package/src/desktop/command.ts +350 -0
- package/src/desktop/edit.ts +217 -0
- package/src/desktop/junit.ts +35 -0
- package/src/desktop/report.ts +155 -0
- package/src/desktop/run.ts +252 -0
- package/src/desktop/setup.ts +134 -0
- package/src/engine/bridge.ts +12 -5
- package/src/engine/setup.ts +3 -3
- package/src/environments.ts +566 -0
- package/src/evals/domain.ts +1 -0
- package/src/explore/charters.ts +2 -0
- package/src/guardrails.ts +145 -0
- package/src/heal/loop.ts +4 -1
- package/src/heal/prompt.ts +2 -1
- package/src/init.ts +5 -2
- package/src/llm.ts +23 -2
- package/src/mcp/attachtools.ts +16 -4
- package/src/mcp/captools.ts +27 -0
- package/src/mcp/containertools.ts +212 -0
- package/src/mcp/desktoptools.ts +334 -0
- package/src/mcp/mobiletools.ts +132 -0
- package/src/mcp/reporttools.ts +17 -2
- package/src/mcp/repotests.ts +22 -3
- package/src/mcp/routing.ts +11 -5
- package/src/mcp/scope.ts +210 -0
- package/src/mcp/server.ts +80 -5
- package/src/mcp/tools.ts +38 -1
- package/src/mobile/command.ts +245 -0
- package/src/mobile/run.ts +226 -0
- package/src/pack/pipeline.ts +44 -2
- package/src/refine.ts +3 -0
- package/src/report/html.ts +3 -0
- package/src/report/template.html +48 -48
- package/src/run.ts +7 -3
- package/src/tokenomics/ledger.ts +3 -1
- package/src/tui/App.tsx +23 -0
- 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
|