@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.
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,338 @@
1
+ """
2
+ Desktop workflows as YAML (format ``forgeqe-desktop-v1``): load, validate, save, and fill in variables.
3
+
4
+ A workflow may have been written by the model or edited by hand, so everything in it is checked before a step runs:
5
+ known ops and keys only, locators by UIA properties, key chords from a small grammar without the Windows key, image
6
+ names that stay inside the workflow's baselines folder.
7
+
8
+ Timing: ``timeout`` (seconds to wait for a control) and ``retries`` (tries again after a failure) apply to the whole
9
+ workflow and can be set per step; ``wait_for`` waits for a control to appear, show a text or go away (a splash
10
+ screen, a progress bar); ``dismiss`` lists controls (a dialog's OK button) to click whenever they are in the way.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Dict, List, Mapping, Optional
18
+
19
+ import yaml
20
+
21
+ FORMAT = "forgeqe-desktop-v1"
22
+ # Mobile apps (Android and iOS, through Appium or Maestro): the same steps where they make sense, plus touch gestures.
23
+ MOBILE_FORMAT = "forgeqe-mobile-v1"
24
+ FORMATS = (FORMAT, MOBILE_FORMAT)
25
+
26
+ OPS = ("launch", "click", "double_click", "type", "select", "keys", "expect", "expect_image", "wait", "wait_for", "close")
27
+ TARGET_OPS = ("click", "double_click", "type", "select", "expect", "wait_for")
28
+ MAX_WAIT_SECONDS = 60
29
+ MAX_TIMEOUT_SECONDS = 300
30
+ MAX_RETRIES = 5
31
+ MAX_DISMISS = 10
32
+ # Steps whose action is tried again after a failure, when retries allow.
33
+ RETRY_OPS = ("click", "double_click", "type", "select", "keys", "expect", "expect_image")
34
+ TIMEOUT_OPS = ("launch", *TARGET_OPS, "expect_image", "long_press", "double_tap", "tap", "scroll_to")
35
+
36
+ MOBILE_OPS = ("launch", "tap", "double_tap", "long_press", "type", "expect", "expect_image", "swipe", "scroll_to", "back", "hide_keyboard", "keys", "wait", "wait_for", "close")
37
+ MOBILE_TARGET_OPS = ("tap", "double_tap", "long_press", "type", "expect", "wait_for", "scroll_to")
38
+ MOBILE_RETRY_OPS = ("tap", "double_tap", "long_press", "type", "expect", "expect_image", "swipe", "scroll_to", "keys")
39
+ MOBILE_KEYS = ("enter", "back", "tab", "delete", "search")
40
+ SWIPE_DIRECTIONS = ("up", "down", "left", "right")
41
+ _MOBILE_STEP_KEYS = {
42
+ "launch": set(),
43
+ "tap": {"target"},
44
+ "double_tap": {"target"},
45
+ "long_press": {"target"},
46
+ "type": {"target", "text"},
47
+ "expect": {"target", "text"},
48
+ "expect_image": {"target", "image", "tolerance", "ignore"},
49
+ "swipe": {"direction", "target"},
50
+ "scroll_to": {"target", "direction"},
51
+ "back": set(),
52
+ "hide_keyboard": set(),
53
+ "keys": {"keys"},
54
+ "wait": {"seconds"},
55
+ "wait_for": {"target", "text", "gone"},
56
+ "close": set(),
57
+ }
58
+ _MOBILE_REQUIRED = {"type": {"target", "text"}, "keys": {"keys"}, "wait": {"seconds"}, "expect_image": {"image"}, "swipe": {"direction"}}
59
+ IMAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,99}\.png$")
60
+ MAX_IGNORE = 20
61
+
62
+ LOCATOR_KEYS = ("automation_id", "name", "control_type", "class_name", "index", "path")
63
+ _STEP_KEYS = {
64
+ "launch": set(),
65
+ "click": {"target"},
66
+ "double_click": {"target"},
67
+ "type": {"target", "text"},
68
+ "select": {"target", "value"},
69
+ "keys": {"keys"},
70
+ "expect": {"target", "text"},
71
+ "expect_image": {"target", "image", "tolerance", "ignore"},
72
+ "wait": {"seconds"},
73
+ "wait_for": {"target", "text", "gone"},
74
+ "close": set(),
75
+ }
76
+ _REQUIRED = {"type": {"target", "text"}, "select": {"target", "value"}, "keys": {"keys"}, "wait": {"seconds"}, "expect_image": {"image"}}
77
+
78
+ APP_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
79
+ _VAR_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
80
+ # {{name}} or {{secret:NAME}}; anything else between braces is an error, not text to keep.
81
+ _PLACEHOLDER = re.compile(r"\{\{\s*([^{}]*?)\s*\}\}")
82
+
83
+ MODIFIERS = ("ctrl", "shift", "alt")
84
+ NAMED_KEYS = (
85
+ "enter", "tab", "esc", "space", "backspace", "delete", "insert", "home", "end", "pageup", "pagedown",
86
+ "up", "down", "left", "right", *[f"f{n}" for n in range(1, 13)],
87
+ )
88
+ # Win+R and the context-menu key reach other programs; typing after them can run any command.
89
+ REFUSED_KEYS = ("win", "lwin", "rwin", "windows", "super", "meta", "cmd", "apps", "menu")
90
+
91
+
92
+ class WorkflowError(ValueError):
93
+ """A workflow that cannot run as written; the message says which step and why."""
94
+
95
+
96
+ def parse_chord(chord: str) -> List[str]:
97
+ """``"ctrl+shift+s"`` as ``["ctrl", "shift", "s"]``: modifiers first, then one named key or one character."""
98
+ if not isinstance(chord, str) or not chord.strip():
99
+ raise WorkflowError("keys must be a chord such as ctrl+s or enter")
100
+ parts = [p.strip().lower() for p in chord.split("+")]
101
+ if chord.strip().endswith("++"):
102
+ parts = parts[:-2] + ["+"]
103
+ if any(not p for p in parts):
104
+ raise WorkflowError(f"keys {chord!r} has an empty part")
105
+ for p in parts:
106
+ if p in REFUSED_KEYS:
107
+ raise WorkflowError(f"keys {chord!r}: the {p} key is not allowed, it reaches programs outside the app")
108
+ *mods, key = parts
109
+ for m in mods:
110
+ if m not in MODIFIERS:
111
+ raise WorkflowError(f"keys {chord!r}: {m!r} is not ctrl, shift or alt")
112
+ if len(set(mods)) != len(mods):
113
+ raise WorkflowError(f"keys {chord!r} repeats a modifier")
114
+ if key not in NAMED_KEYS and not (len(key) == 1 and key.isprintable() and not key.isspace()):
115
+ raise WorkflowError(f"keys {chord!r}: {key!r} is not a key (use one character or {', '.join(NAMED_KEYS[:6])}, …)")
116
+ return [*mods, key]
117
+
118
+
119
+ def _check_target(where: str, target: Any) -> Dict[str, Any]:
120
+ if not isinstance(target, dict):
121
+ raise WorkflowError(f"{where}: target must be a mapping of UIA properties")
122
+ unknown = set(target) - set(LOCATOR_KEYS)
123
+ if unknown:
124
+ raise WorkflowError(f"{where}: unknown target keys {sorted(unknown)}; use {', '.join(LOCATOR_KEYS)}")
125
+ if not str(target.get("automation_id") or "").strip() and not str(target.get("name") or "").strip():
126
+ raise WorkflowError(f"{where}: target needs an automation_id or a name")
127
+ for k in ("automation_id", "name", "control_type", "class_name"):
128
+ if k in target and not isinstance(target[k], str):
129
+ raise WorkflowError(f"{where}: target.{k} must be text")
130
+ if "index" in target and (not isinstance(target["index"], int) or isinstance(target["index"], bool) or target["index"] < 0):
131
+ raise WorkflowError(f"{where}: target.index must be a whole number, 0 or more")
132
+ if "path" in target:
133
+ path = target["path"]
134
+ if not isinstance(path, list) or not all(isinstance(p, dict) and set(p) <= {"control_type", "name"} for p in path):
135
+ raise WorkflowError(f"{where}: target.path must be a list of {{control_type, name}}")
136
+ return dict(target)
137
+
138
+
139
+ def _seconds(where: str, value: Any, most: float) -> float:
140
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not 0 <= value <= most:
141
+ raise WorkflowError(f"{where} must be a number of seconds between 0 and {most:g}")
142
+ return value
143
+
144
+
145
+ def _retries(where: str, value: Any) -> int:
146
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= MAX_RETRIES:
147
+ raise WorkflowError(f"{where} must be a whole number from 0 to {MAX_RETRIES}")
148
+ return value
149
+
150
+
151
+ def check_image_name(where: str, name: Any) -> str:
152
+ """A baseline image's file name: letters, digits, _ and -, ending .png; never a path."""
153
+ if not isinstance(name, str) or not IMAGE_NAME.match(name):
154
+ raise WorkflowError(f"{where}: image must be a file name such as invoice-total.png (letters, digits, _ and -), not a path")
155
+ return name
156
+
157
+
158
+ def _ignore(where: str, regions: Any) -> List[List[int]]:
159
+ if not isinstance(regions, list) or len(regions) > MAX_IGNORE:
160
+ raise WorkflowError(f"{where}: ignore must be a list of at most {MAX_IGNORE} regions [x, y, width, height]")
161
+ out: List[List[int]] = []
162
+ for r in regions:
163
+ if not (isinstance(r, list) and len(r) == 4 and all(isinstance(n, int) and not isinstance(n, bool) and n >= 0 for n in r)):
164
+ raise WorkflowError(f"{where}: each ignore region is [x, y, width, height] in whole pixels")
165
+ out.append(list(r))
166
+ return out
167
+
168
+
169
+ def validate(doc: Any) -> Dict[str, Any]:
170
+ """The workflow, checked and normalised; raises WorkflowError naming the first problem."""
171
+ if not isinstance(doc, dict):
172
+ raise WorkflowError("a workflow is a YAML mapping")
173
+ fmt = doc.get("format")
174
+ if fmt not in FORMATS:
175
+ raise WorkflowError(f"format must be {FORMAT} (Windows desktop) or {MOBILE_FORMAT} (Android, iOS)")
176
+ mobile = fmt == MOBILE_FORMAT
177
+ ops, step_keys, required = (MOBILE_OPS, _MOBILE_STEP_KEYS, _MOBILE_REQUIRED) if mobile else (OPS, _STEP_KEYS, _REQUIRED)
178
+ target_ops, retry_ops = (MOBILE_TARGET_OPS, MOBILE_RETRY_OPS) if mobile else (TARGET_OPS, RETRY_OPS)
179
+ app = doc.get("app")
180
+ if not isinstance(app, str) or not APP_NAME.match(app):
181
+ raise WorkflowError(f"app must name an entry in forgeqe.yaml {'mobile' if mobile else 'desktop'}.apps (letters, digits, _ . -), not a path")
182
+ variables = doc.get("variables") or {}
183
+ if not isinstance(variables, dict):
184
+ raise WorkflowError("variables must be a mapping")
185
+ for k, v in variables.items():
186
+ if not isinstance(k, str) or not _VAR_NAME.match(k):
187
+ raise WorkflowError(f"variable name {k!r} must be letters, digits and _")
188
+ if not isinstance(v, (str, int, float)) or isinstance(v, bool):
189
+ raise WorkflowError(f"variable {k} must be text or a number")
190
+ steps = doc.get("steps")
191
+ if not isinstance(steps, list) or not steps:
192
+ raise WorkflowError("steps must be a non-empty list")
193
+ out: List[Dict[str, Any]] = []
194
+ for i, step in enumerate(steps, 1):
195
+ where = f"step {i}"
196
+ if not isinstance(step, dict):
197
+ raise WorkflowError(f"{where} must be a mapping")
198
+ op = step.get("op")
199
+ if op not in ops:
200
+ raise WorkflowError(f"{where}: op {op!r} is not one of {', '.join(ops)}")
201
+ allowed = step_keys[op] | {"op", "note"} | ({"timeout"} if op in TIMEOUT_OPS else set()) | ({"retries"} if op in retry_ops else set())
202
+ unknown = set(step) - allowed
203
+ if unknown:
204
+ raise WorkflowError(f"{where} ({op}): unknown keys {sorted(unknown)}")
205
+ missing = (required.get(op, set()) | ({"target"} if op in target_ops else set())) - set(step)
206
+ if missing:
207
+ raise WorkflowError(f"{where} ({op}) needs {', '.join(sorted(missing))}")
208
+ clean: Dict[str, Any] = {"op": op}
209
+ if "target" in step:
210
+ clean["target"] = _check_target(where, step["target"])
211
+ for k in ("text", "value"):
212
+ if k in step:
213
+ if not isinstance(step[k], (str, int, float)) or isinstance(step[k], bool):
214
+ raise WorkflowError(f"{where}: {k} must be text")
215
+ clean[k] = str(step[k])
216
+ if op == "keys" and mobile:
217
+ key = str(step["keys"]).strip().lower()
218
+ if key not in MOBILE_KEYS:
219
+ raise WorkflowError(f"{where}: keys on a phone is one of {', '.join(MOBILE_KEYS)}")
220
+ clean["keys"] = key
221
+ elif op == "keys":
222
+ parse_chord(step["keys"])
223
+ clean["keys"] = str(step["keys"]).strip().lower()
224
+ if op in ("swipe", "scroll_to") and "direction" in step:
225
+ if step["direction"] not in SWIPE_DIRECTIONS:
226
+ raise WorkflowError(f"{where}: direction is one of {', '.join(SWIPE_DIRECTIONS)}")
227
+ clean["direction"] = step["direction"]
228
+ if op == "wait":
229
+ s = step["seconds"]
230
+ if not isinstance(s, (int, float)) or isinstance(s, bool) or not 0 <= s <= MAX_WAIT_SECONDS:
231
+ raise WorkflowError(f"{where}: seconds must be between 0 and {MAX_WAIT_SECONDS}")
232
+ clean["seconds"] = s
233
+ if op == "wait_for":
234
+ if "gone" in step and not isinstance(step["gone"], bool):
235
+ raise WorkflowError(f"{where}: gone must be true or false")
236
+ if step.get("gone") and "text" in step:
237
+ raise WorkflowError(f"{where}: wait_for waits for a control to go away (gone) or to show a text, not both")
238
+ if step.get("gone"):
239
+ clean["gone"] = True
240
+ if op == "expect_image":
241
+ clean["image"] = check_image_name(where, step["image"])
242
+ if "tolerance" in step:
243
+ t = step["tolerance"]
244
+ if not isinstance(t, (int, float)) or isinstance(t, bool) or not 0 <= t <= 1:
245
+ raise WorkflowError(f"{where}: tolerance is the share of pixels that may differ, from 0 to 1 (0.01 is 1%)")
246
+ clean["tolerance"] = t
247
+ if "ignore" in step:
248
+ clean["ignore"] = _ignore(where, step["ignore"])
249
+ if "timeout" in step:
250
+ clean["timeout"] = _seconds(f"{where}: timeout", step["timeout"], MAX_TIMEOUT_SECONDS)
251
+ if "retries" in step:
252
+ clean["retries"] = _retries(f"{where}: retries", step["retries"])
253
+ if isinstance(step.get("note"), str):
254
+ clean["note"] = step["note"]
255
+ out.append(clean)
256
+ result: Dict[str, Any] = {"format": fmt, "name": str(doc.get("name") or ""), "app": app, "variables": dict(variables), "steps": out}
257
+ if "timeout" in doc:
258
+ result["timeout"] = _seconds("timeout", doc["timeout"], MAX_TIMEOUT_SECONDS)
259
+ if "retries" in doc:
260
+ result["retries"] = _retries("retries", doc["retries"])
261
+ if "dismiss" in doc:
262
+ dismiss = doc["dismiss"]
263
+ if not isinstance(dismiss, list) or len(dismiss) > MAX_DISMISS:
264
+ raise WorkflowError(f"dismiss must be a list of at most {MAX_DISMISS} targets (the buttons that close a dialog in the way)")
265
+ result["dismiss"] = [_check_target(f"dismiss {i}", t) for i, t in enumerate(dismiss, 1)]
266
+ return result
267
+
268
+
269
+ def load(path: str | os.PathLike) -> Dict[str, Any]:
270
+ with open(path, "r", encoding="utf-8") as f:
271
+ return validate(yaml.safe_load(f))
272
+
273
+
274
+ def dump(workflow: Dict[str, Any]) -> str:
275
+ return yaml.safe_dump(validate(workflow), sort_keys=False, allow_unicode=True)
276
+
277
+
278
+ def save(workflow: Dict[str, Any], path: str | os.PathLike) -> None:
279
+ Path(path).write_text(dump(workflow), encoding="utf-8")
280
+
281
+
282
+ def env_secret(name: str) -> Optional[str]:
283
+ return os.environ.get(name)
284
+
285
+
286
+ def fill(text: str, variables: Mapping[str, Any], secret: Callable[[str], Optional[str]] = env_secret) -> str:
287
+ """
288
+ ``text`` with ``{{name}}`` and ``{{secret:NAME}}`` replaced, in one pass: a value that itself contains ``{{…}}`` is
289
+ typed as it is, never expanded again. An unknown variable or secret is an error, not an empty string.
290
+ """
291
+
292
+ def one(m: "re.Match[str]") -> str:
293
+ ref = m.group(1)
294
+ if ref.startswith("secret:"):
295
+ name = ref[len("secret:"):].strip()
296
+ if not _VAR_NAME.match(name):
297
+ raise WorkflowError(f"secret name {name!r} must be letters, digits and _")
298
+ value = secret(name)
299
+ if value is None:
300
+ raise WorkflowError(f"secret {name} is not set")
301
+ return value
302
+ if not _VAR_NAME.match(ref):
303
+ raise WorkflowError(f"{{{{{ref}}}}} is not a variable name")
304
+ if ref not in variables:
305
+ raise WorkflowError(f"variable {ref} has no value")
306
+ return str(variables[ref])
307
+
308
+ return _PLACEHOLDER.sub(one, text)
309
+
310
+
311
+ def fill_targets(workflow: Mapping[str, Any], variables: Mapping[str, Any]) -> Dict[str, Any]:
312
+ """
313
+ The workflow with ``{{name}}`` filled in its targets (steps and ``dismiss``), so a data row can name the control to
314
+ use ("tap the result {{term}}"). A secret is refused there: a locator is shown in reports and to the healing model.
315
+ """
316
+
317
+ def no_secret(name: str) -> Optional[str]:
318
+ raise WorkflowError(f"a target may not use {{{{secret:{name}}}}}; secrets are typed with text, never part of a locator")
319
+
320
+ def one(target: Mapping[str, Any]) -> Dict[str, Any]:
321
+ out = dict(target)
322
+ for k in ("automation_id", "name", "control_type", "class_name"):
323
+ if isinstance(out.get(k), str):
324
+ out[k] = fill(out[k], variables, no_secret)
325
+ if isinstance(out.get("path"), list):
326
+ out["path"] = [{**p, **({"name": fill(p["name"], variables, no_secret)} if isinstance(p.get("name"), str) else {})} for p in out["path"]]
327
+ return out
328
+
329
+ wf = dict(workflow)
330
+ wf["steps"] = [{**s, "target": one(s["target"])} if isinstance(s.get("target"), Mapping) else dict(s) for s in wf.get("steps") or []]
331
+ if wf.get("dismiss"):
332
+ wf["dismiss"] = [one(t) for t in wf["dismiss"]]
333
+ return wf
334
+
335
+
336
+ def secret_names(text: str) -> List[str]:
337
+ """The ``{{secret:NAME}}`` names in ``text``, so a report can mask what they were filled with."""
338
+ return [m.group(1)[len("secret:"):].strip() for m in _PLACEHOLDER.finditer(text) if m.group(1).startswith("secret:")]
@@ -125,13 +125,13 @@ def _build_llm(llm_config: Optional[Dict[str, Any]]):
125
125
  dep = str(deployment)
126
126
  common: Dict[str, Any] = {"model": dep, "azure_deployment": dep, "azure_endpoint": endpoint, "api_version": str(api_version)}
127
127
  if entra:
128
- return ChatAzureOpenAI(**common, azure_ad_token_provider=entra_token_provider(cfg))
128
+ return _adaptive(cfg, ChatAzureOpenAI(**common, azure_ad_token_provider=entra_token_provider(cfg)))
129
129
  if not api_key:
130
130
  raise ValueError(
131
131
  "Azure OpenAI needs an API key: set the profile's api_key_env variable (see llms.json), "
132
132
  "or set auth: entra to sign in with Microsoft Entra ID instead."
133
133
  )
134
- return ChatAzureOpenAI(**common, api_key=api_key)
134
+ return _adaptive(cfg, ChatAzureOpenAI(**common, api_key=api_key))
135
135
 
136
136
  if provider in ("openai", "openai_compatible"):
137
137
  from browser_use.llm.openai.chat import ChatOpenAI
@@ -146,17 +146,67 @@ def _build_llm(llm_config: Optional[Dict[str, Any]]):
146
146
  # The gateway takes an Entra ID bearer token; the client asks for a fresh one on every request.
147
147
  kw["api_key"] = "entra"
148
148
  kw["http_client"] = _bearer_client(entra_token_provider(cfg))
149
- return ChatOpenAI(**kw)
149
+ return _adaptive(cfg, ChatOpenAI(**kw))
150
150
  kw["api_key"] = api_key or "local"
151
151
  else:
152
152
  if not api_key:
153
153
  raise ValueError("OpenAI needs an API key: set OPENAI_API_KEY or the profile's api_key_env.")
154
154
  kw["api_key"] = api_key
155
- return ChatOpenAI(**kw)
155
+ return _adaptive(cfg, ChatOpenAI(**kw))
156
156
 
157
157
  raise ValueError(f"Unsupported LLM provider for the engine: {provider}")
158
158
 
159
159
 
160
+ def _adaptive(cfg: Dict[str, Any], llm: Any) -> Any:
161
+ """
162
+ A browser_use OpenAI or Azure chat model that sends the parameters its model takes (``model_params``): reasoning
163
+ models are told apart by the profile's model name, not only the deployment's, and a refusal of a parameter turns
164
+ that parameter off and sends the step again.
165
+ """
166
+ from .model_params import MAX_ADJUSTMENTS, is_reasoning, refused_parameters
167
+
168
+ said = cfg.get("reasoning")
169
+ if said is False:
170
+ llm.reasoning_models = []
171
+ elif is_reasoning(cfg):
172
+ llm.reasoning_models = [*(llm.reasoning_models or []), str(llm.model)]
173
+ original = llm.ainvoke
174
+
175
+ async def ainvoke(messages: Any, output_format: Any = None, **kwargs: Any) -> Any:
176
+ for attempt in range(MAX_ADJUSTMENTS + 1):
177
+ try:
178
+ return await original(messages, output_format, **kwargs)
179
+ except Exception as e:
180
+ changed = _turn_off(llm, refused_parameters(e))
181
+ if not changed or attempt == MAX_ADJUSTMENTS:
182
+ raise
183
+ logger.info("model %s refused %s; sending the step again without it", llm.model, ", ".join(changed))
184
+
185
+ llm.ainvoke = ainvoke
186
+ return llm
187
+
188
+
189
+ def _turn_off(llm: Any, refused: Any) -> List[str]:
190
+ """Change the browser_use model so it no longer sends the refused parameters; returns what changed."""
191
+ changed: List[str] = []
192
+
193
+ def unset(attr: str, value: Any = None) -> None:
194
+ if getattr(llm, attr, value) != value:
195
+ setattr(llm, attr, value)
196
+ changed.append(attr)
197
+
198
+ for key in sorted(refused):
199
+ if key in ("temperature", "frequency_penalty", "top_p", "seed", "service_tier", "max_completion_tokens"):
200
+ unset(key)
201
+ elif key == "reasoning_effort" and llm.reasoning_models:
202
+ llm.reasoning_models = []
203
+ changed.append(key)
204
+ elif key == "response_format":
205
+ unset("dont_force_structured_output", True)
206
+ unset("add_schema_to_system_prompt", True)
207
+ return changed
208
+
209
+
160
210
  def call_usage(result: Any) -> Dict[str, int]:
161
211
  """One model call's tokens from its completion (browser-use ChatInvokeUsage); prompt tokens include cached ones."""
162
212
  u = getattr(result, "usage", None)
@@ -0,0 +1,169 @@
1
+ """
2
+ Request parameters that suit the model behind an llms.json profile, worked out without the user setting them.
3
+
4
+ Chat models differ in what they accept: reasoning models (o1, o3, o4, gpt-5, ...) take ``max_completion_tokens``
5
+ instead of ``max_tokens``, only the default temperature, and no ``frequency_penalty``; older models and many
6
+ OpenAI-compatible servers know nothing of ``max_completion_tokens`` or ``reasoning_effort``; some servers refuse
7
+ ``response_format``. An Azure deployment's name says nothing of the model behind it.
8
+
9
+ So parameters are settled in two ways:
10
+
11
+ - **Up front:** a profile is a reasoning model when it says so (``"reasoning": true`` or ``false`` in llms.json), else
12
+ when its ``model`` or ``deployment`` name is one of the reasoning families.
13
+ - **From the API's answer:** a 400 that names a parameter or value the model refuses changes that parameter (switch
14
+ ``max_tokens`` and ``max_completion_tokens``, drop the rest) and the call is sent again. What was learned is kept per
15
+ model for the rest of the process, so only the first call pays for it.
16
+
17
+ Only parameters in ``ADJUSTABLE`` are ever changed or dropped; messages, the model and tools never are.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import threading
23
+ from typing import Any, Dict, Mapping, Optional, Set
24
+
25
+ # Model families that reason before answering: they refuse max_tokens and a temperature other than the default.
26
+ _REASONING = re.compile(r"(^|[^a-z0-9])(o1|o3|o4|gpt-5|gpt5|codex|deepseek-r1|qwq)([^a-z0-9]|$)|reason", re.I)
27
+ _LIMITS = ("max_tokens", "max_completion_tokens")
28
+ # Parameters that only shape sampling or the reply's form; a call works without them.
29
+ DROPPABLE = ("temperature", "top_p", "frequency_penalty", "presence_penalty", "response_format", "reasoning_effort", "service_tier", "seed", "logprobs", "top_logprobs", "parallel_tool_calls")
30
+ ADJUSTABLE = frozenset(_LIMITS + DROPPABLE)
31
+ # Most retries for one call: each one fixes a different parameter.
32
+ MAX_ADJUSTMENTS = 4
33
+ # A reasoning model can spend a small limit on reasoning alone and answer nothing; the limit it then gets.
34
+ REASONING_ROOM = 2048
35
+
36
+ _PARAM = re.compile(r"""(?:parameter|param|argument|field|value)[^'"`]{0,40}['"`]([a-z_]+)['"`]|['"`]([a-z_]+)['"`][^.]{0,60}(?:not supported|unsupported|unrecognized|unknown|not allowed|extra (?:fields|inputs) not permitted|does not support)""", re.I)
37
+
38
+ _PARAM_FIELD = re.compile(r"""['"]param['"]\s*:\s*['"]([a-z_]+)['"]|(?:argument|parameter)s?\s+supplied:\s*([a-z_]+)""", re.I)
39
+
40
+
41
+ def is_reasoning(profile: Mapping[str, Any]) -> bool:
42
+ """A reasoning model: ``reasoning`` in llms.json when set, else the model or deployment name."""
43
+ said = profile.get("reasoning")
44
+ if isinstance(said, bool):
45
+ return said
46
+ names = " ".join(str(profile.get(k) or "") for k in ("model", "deployment", "deploymentName"))
47
+ return bool(_REASONING.search(names))
48
+
49
+
50
+ def refused_parameters(error: Any) -> Set[str]:
51
+ """The adjustable parameters a provider's error says the model refuses (empty when it is another error)."""
52
+ text = str(error)
53
+ if not re.search(r"\b400\b|bad ?request|invalid_request|unsupported|not supported|unrecognized|unknown (?:parameter|field)|extra (?:fields|inputs)", text, re.I):
54
+ return set()
55
+ found = {(m.group(1) or m.group(2)).lower() for m in _PARAM.finditer(text)}
56
+ # The error body's own "param" field, and "argument supplied: name" without quotes.
57
+ found |= {(m.group(1) or m.group(2)).lower() for m in _PARAM_FIELD.finditer(text)}
58
+ if re.search(r"temperature.{0,80}(only the default|does not support|not supported|unsupported)|(only the default|unsupported value).{0,80}temperature", text, re.I | re.S):
59
+ found.add("temperature")
60
+ if "max_completion_tokens" in text and "max_tokens" in text and re.search(r"use ['\"`]?max_completion_tokens", text, re.I):
61
+ found = (found - {"max_completion_tokens"}) | {"max_tokens"}
62
+ return found & ADJUSTABLE
63
+
64
+
65
+ class ModelParams:
66
+ """What one model accepts, starting from its profile and learning from its refusals; safe across threads."""
67
+
68
+ def __init__(self, profile: Mapping[str, Any]) -> None:
69
+ self.reasoning = is_reasoning(profile)
70
+ self.limit_key = "max_completion_tokens" if self.reasoning else "max_tokens"
71
+ self.dropped: Set[str] = set()
72
+ if self.reasoning:
73
+ self.dropped |= {"temperature", "top_p", "frequency_penalty", "presence_penalty"}
74
+ self._lock = threading.Lock()
75
+
76
+ def apply(self, kw: Dict[str, Any]) -> Dict[str, Any]:
77
+ """``kw`` with the token limit under the key this model takes and the parameters it refuses left out."""
78
+ out = dict(kw)
79
+ for key in _LIMITS:
80
+ if key in out and key != self.limit_key:
81
+ out[self.limit_key] = out.pop(key)
82
+ if self.reasoning and self.limit_key in out and out[self.limit_key] is not None:
83
+ # The limit covers reasoning too: a small one leaves nothing for the answer.
84
+ out[self.limit_key] = max(int(out[self.limit_key]), REASONING_ROOM)
85
+ for key in self.dropped:
86
+ out.pop(key, None)
87
+ return out
88
+
89
+ def learn(self, error: Any, sent: Mapping[str, Any]) -> bool:
90
+ """Take in a refusal of what was ``sent``; True when something changed and the call is worth sending again."""
91
+ refused = refused_parameters(error) & set(sent)
92
+ if not refused:
93
+ return False
94
+ changed = False
95
+ with self._lock:
96
+ for key in refused:
97
+ if key in _LIMITS:
98
+ other = _LIMITS[1] if key == _LIMITS[0] else _LIMITS[0]
99
+ if self.limit_key == key:
100
+ self.limit_key = other
101
+ changed = True
102
+ if other == "max_completion_tokens":
103
+ self.reasoning = True
104
+ elif key not in self.dropped:
105
+ self.dropped.add(key)
106
+ changed = True
107
+ return changed
108
+
109
+ def needs_room(self, response: Any, sent: Mapping[str, Any]) -> bool:
110
+ """An empty answer cut off by the token limit: the model reasoned through all of it. Raises the limit for next time."""
111
+ choices = getattr(response, "choices", None) or []
112
+ if not choices:
113
+ return False
114
+ first = choices[0]
115
+ content = getattr(getattr(first, "message", None), "content", None)
116
+ if content or getattr(first, "finish_reason", None) != "length":
117
+ return False
118
+ limit = sent.get(self.limit_key)
119
+ if not isinstance(limit, int) or limit >= REASONING_ROOM:
120
+ return False
121
+ with self._lock:
122
+ self.reasoning = True
123
+ return True
124
+
125
+
126
+ _SHARED: Dict[str, ModelParams] = {}
127
+ _SHARED_LOCK = threading.Lock()
128
+
129
+
130
+ def params_for(profile: Mapping[str, Any]) -> ModelParams:
131
+ """One ``ModelParams`` per model (provider, endpoint and deployment or model) for the whole process."""
132
+ key = "|".join(str(profile.get(k) or "") for k in ("provider", "endpoint", "deployment", "model", "reasoning"))
133
+ with _SHARED_LOCK:
134
+ p = _SHARED.get(key)
135
+ if p is None:
136
+ p = _SHARED[key] = ModelParams(profile)
137
+ return p
138
+
139
+
140
+ def call_with_params(params: ModelParams, create: Any, kw: Mapping[str, Any]) -> Any:
141
+ """``create(**kw)`` with the parameters this model takes, sent again after each refusal that teaches something."""
142
+ for _ in range(MAX_ADJUSTMENTS + 1):
143
+ sent = params.apply(dict(kw))
144
+ try:
145
+ response = create(**sent)
146
+ except Exception as e:
147
+ if params.learn(e, sent):
148
+ continue
149
+ raise
150
+ if params.needs_room(response, sent):
151
+ continue
152
+ return response
153
+ return create(**params.apply(dict(kw)))
154
+
155
+
156
+ async def acall_with_params(params: ModelParams, create: Any, kw: Mapping[str, Any]) -> Any:
157
+ """``call_with_params`` for an async ``create``."""
158
+ for _ in range(MAX_ADJUSTMENTS + 1):
159
+ sent = params.apply(dict(kw))
160
+ try:
161
+ response = await create(**sent)
162
+ except Exception as e:
163
+ if params.learn(e, sent):
164
+ continue
165
+ raise
166
+ if params.needs_room(response, sent):
167
+ continue
168
+ return response
169
+ return await create(**params.apply(dict(kw)))
@@ -0,0 +1 @@
1
+ """ForgeQE's mobile engine: Android and iOS apps through Appium or Maestro (docs/MOBILE.md)."""
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())