@capagents/forgeqe 2.3.0 → 2.3.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/dist/forgeqe.js +557 -557
- package/package.json +1 -1
- package/py/forgeqe_engine/copilot_gateway.py +79 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"repository": {
|
|
@@ -290,6 +290,9 @@ class Copilot:
|
|
|
290
290
|
self.workdir = tempfile.mkdtemp(prefix="forgeqe-copilot-")
|
|
291
291
|
self.signed_in_user = not token
|
|
292
292
|
self.client = CopilotClient(**client_options(token, home, self.workdir))
|
|
293
|
+
# Caller tools named like one of Copilot's own: they must say they replace it. Learned from Copilot's answers
|
|
294
|
+
# (BUILTIN_TOOLS to start with), kept for the gateway's life, so only a first request pays a retry.
|
|
295
|
+
self.replaces: set = set(BUILTIN_TOOLS)
|
|
293
296
|
|
|
294
297
|
async def start(self) -> None:
|
|
295
298
|
await self.client.start()
|
|
@@ -343,38 +346,54 @@ class Copilot:
|
|
|
343
346
|
elif isinstance(data, SessionErrorData):
|
|
344
347
|
errors.append(data.message or data.error_type or "error")
|
|
345
348
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
session =
|
|
369
|
-
try:
|
|
370
|
-
reply = await session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
|
|
371
|
-
except TimeoutError:
|
|
372
|
-
raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
|
|
373
|
-
finally:
|
|
349
|
+
tried: set = set()
|
|
350
|
+
reply: Any = None
|
|
351
|
+
for _ in range(MAX_TOOL_RETRIES + 1):
|
|
352
|
+
errors.clear()
|
|
353
|
+
calls.clear()
|
|
354
|
+
names = {t["name"] for t in req["tools"]}
|
|
355
|
+
tools = relay_tools(req["tools"], calls, replaces=self.replaces & names)
|
|
356
|
+
options: Dict[str, Any] = dict(
|
|
357
|
+
model=req["model"],
|
|
358
|
+
# The caller's prompt is the whole prompt; Copilot's own tools are never offered, only the caller's.
|
|
359
|
+
system_message={"mode": "replace", "content": req["system"] or "You are a helpful assistant."},
|
|
360
|
+
tools=tools or None,
|
|
361
|
+
available_tools=[t.name for t in tools],
|
|
362
|
+
on_permission_request=lambda *_: PermissionDecisionReject(),
|
|
363
|
+
working_directory=self.workdir,
|
|
364
|
+
skip_custom_instructions=True,
|
|
365
|
+
enable_config_discovery=False,
|
|
366
|
+
enable_skills=False,
|
|
367
|
+
enable_session_store=False,
|
|
368
|
+
on_event=on_event,
|
|
369
|
+
)
|
|
370
|
+
failure: Optional[str] = None
|
|
371
|
+
session = None
|
|
374
372
|
try:
|
|
375
|
-
await
|
|
376
|
-
|
|
377
|
-
|
|
373
|
+
session = await self.client.create_session(**options)
|
|
374
|
+
reply = await session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
|
|
375
|
+
except TimeoutError:
|
|
376
|
+
raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
|
|
377
|
+
except Exception as e: # Copilot names a tool it will not take; anything else is reported below.
|
|
378
|
+
failure = str(e)
|
|
379
|
+
finally:
|
|
380
|
+
if session is not None:
|
|
381
|
+
try:
|
|
382
|
+
await session.disconnect()
|
|
383
|
+
except Exception:
|
|
384
|
+
pass
|
|
385
|
+
# Copilot reports a clash with its own tools when the session starts or when the turn starts.
|
|
386
|
+
fix = tool_conflict(failure or (errors[-1] if errors and not calls else ""), names)
|
|
387
|
+
if fix and fix not in tried:
|
|
388
|
+
tried.add(fix)
|
|
389
|
+
name, replace = fix
|
|
390
|
+
(self.replaces.add if replace else self.replaces.discard)(name)
|
|
391
|
+
continue
|
|
392
|
+
if failure:
|
|
393
|
+
raise GatewayError(502, f"GitHub Copilot: {failure}"[:2000])
|
|
394
|
+
break
|
|
395
|
+
else:
|
|
396
|
+
raise GatewayError(502, "GitHub Copilot kept refusing the names of the caller's tools.")
|
|
378
397
|
if errors and not calls:
|
|
379
398
|
msg = errors[-1]
|
|
380
399
|
raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
|
|
@@ -389,10 +408,36 @@ class Copilot:
|
|
|
389
408
|
return completion(req["model"], content, usage)
|
|
390
409
|
|
|
391
410
|
|
|
392
|
-
|
|
411
|
+
# Copilot's own tools that the agent runtime's tools are named like (bash, glob, grep, edit, …). A caller tool with one
|
|
412
|
+
# of these names says it replaces Copilot's: the caller's tool is the one offered, and it runs nothing here.
|
|
413
|
+
BUILTIN_TOOLS = frozenset({"bash", "powershell", "glob", "grep", "view", "edit", "create", "task", "skill", "web_fetch", "think", "update_todo", "report_intent"})
|
|
414
|
+
MAX_TOOL_RETRIES = 8
|
|
415
|
+
|
|
416
|
+
_CONFLICT = re.compile(r'(?:external )?tool "([A-Za-z0-9_-]{1,64})" conflicts with a built-in tool', re.I)
|
|
417
|
+
_NO_BUILTIN = re.compile(r'tool "([A-Za-z0-9_-]{1,64})"[^"\n]{0,80}(?:does not|doesn\'t|no) (?:override|built-in|builtin)', re.I)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def tool_conflict(message: str, names: set) -> Optional[Tuple[str, bool]]:
|
|
421
|
+
"""
|
|
422
|
+
From Copilot's error, the caller tool to retry with: (name, True) when it clashes with a built-in and must replace
|
|
423
|
+
it, (name, False) when Copilot has no built-in of that name to replace. None for any other error.
|
|
424
|
+
"""
|
|
425
|
+
if not message:
|
|
426
|
+
return None
|
|
427
|
+
m = _CONFLICT.search(message)
|
|
428
|
+
if m and m.group(1) in names:
|
|
429
|
+
return m.group(1), True
|
|
430
|
+
m = _NO_BUILTIN.search(message)
|
|
431
|
+
if m and m.group(1) in names:
|
|
432
|
+
return m.group(1), False
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], override: bool = False, replaces: Optional[set] = None) -> List[Any]:
|
|
393
437
|
"""
|
|
394
438
|
The caller's tools as Copilot SDK tools that run nothing: each records the call in `calls` and, being terminal, ends
|
|
395
|
-
the turn, so the call goes back to the caller as OpenAI tool_calls.
|
|
439
|
+
the turn, so the call goes back to the caller as OpenAI tool_calls. A tool in `replaces` (or every tool, with
|
|
440
|
+
`override`) replaces Copilot's built-in tool of the same name.
|
|
396
441
|
"""
|
|
397
442
|
if not specs:
|
|
398
443
|
return []
|
|
@@ -408,7 +453,7 @@ def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], overri
|
|
|
408
453
|
return ToolResult(text_result_for_llm="The caller runs this tool and sends its result in the next message.")
|
|
409
454
|
|
|
410
455
|
return [
|
|
411
|
-
Tool(name=t["name"], description=t["description"], parameters=t["parameters"], handler=handler, skip_permission=True, is_terminal=True, defer="never", overrides_built_in_tool=override)
|
|
456
|
+
Tool(name=t["name"], description=t["description"], parameters=t["parameters"], handler=handler, skip_permission=True, is_terminal=True, defer="never", overrides_built_in_tool=override or t["name"] in (replaces or set()))
|
|
412
457
|
for t in specs
|
|
413
458
|
]
|
|
414
459
|
|