@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "2.3.0",
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
- tools = relay_tools(req["tools"], calls)
347
- options: Dict[str, Any] = dict(
348
- model=req["model"],
349
- # The caller's prompt is the whole prompt; Copilot's own tools are never offered, only the caller's.
350
- system_message={"mode": "replace", "content": req["system"] or "You are a helpful assistant."},
351
- tools=tools or None,
352
- available_tools=[t.name for t in tools],
353
- on_permission_request=lambda *_: PermissionDecisionReject(),
354
- working_directory=self.workdir,
355
- skip_custom_instructions=True,
356
- enable_config_discovery=False,
357
- enable_skills=False,
358
- enable_session_store=False,
359
- on_event=on_event,
360
- )
361
- try:
362
- session = await self.client.create_session(**options)
363
- except Exception as e:
364
- # A caller's tool named like one of Copilot's (bash, grep, glob) must say it replaces it.
365
- if not tools or "built" not in str(e).lower():
366
- raise
367
- tools = relay_tools(req["tools"], calls, override=True)
368
- session = await self.client.create_session(**{**options, "tools": tools})
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 session.disconnect()
376
- except Exception:
377
- pass
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
- def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], override: bool = False) -> List[Any]:
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