@capagents/forgeqe 1.3.1 → 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.
- 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/cli.py +10 -0
- package/py/forgeqe_engine/llm.py +54 -4
- package/py/forgeqe_engine/model_params.py +169 -0
- package/py/forgeqe_engine/summary.py +6 -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/defects.ts +2 -1
- 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 +6 -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/update.ts +118 -8
- 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:")]
|
package/py/forgeqe_engine/cli.py
CHANGED
|
@@ -171,8 +171,18 @@ def _token(out: TextIO) -> int:
|
|
|
171
171
|
return 1
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
def _utf8_stdio() -> None:
|
|
175
|
+
"""The CLI speaks UTF-8 on every pipe; on Windows a pipe defaults to the ANSI code page, which turns a curly quote into a lone surrogate."""
|
|
176
|
+
for stream in (sys.stdin, sys.stderr):
|
|
177
|
+
try:
|
|
178
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
179
|
+
except (AttributeError, ValueError):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
174
183
|
def main(argv: list[str] | None = None) -> int:
|
|
175
184
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
185
|
+
_utf8_stdio()
|
|
176
186
|
out = _event_stream()
|
|
177
187
|
_configure_env()
|
|
178
188
|
cmd = args[0] if args else "run"
|
package/py/forgeqe_engine/llm.py
CHANGED
|
@@ -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)))
|
|
@@ -81,6 +81,12 @@ def _error_summary_from_history(history: Any) -> str:
|
|
|
81
81
|
n = len(errs)
|
|
82
82
|
if len(distinct) == 1:
|
|
83
83
|
msg = distinct[0]
|
|
84
|
+
if n > 1 and "surrogates not allowed" in msg:
|
|
85
|
+
return (
|
|
86
|
+
f"Run stopped after {n} identical errors: {msg}. "
|
|
87
|
+
"The request to the model held text that was decoded in the wrong encoding, so it was never sent; "
|
|
88
|
+
"the LLM provider is not at fault. Update ForgeQE; the engine now reads its input as UTF-8."
|
|
89
|
+
)
|
|
84
90
|
if n > 1:
|
|
85
91
|
return (
|
|
86
92
|
f"Run stopped after {n} identical errors: {msg}. "
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ForgeQE's mobile engine: Android and iOS apps through Appium or Maestro (docs/MOBILE.md)."""
|