@capagents/forgeqe 2.3.3 → 2.3.4

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 CHANGED
@@ -371,6 +371,7 @@ GitHub Models, which ForgeQE used for the engine before, was [retired by GitHub]
371
371
  | `Model for agent: main, because copilot failed its live check …` | ForgeQE used your other model because Copilot did not answer; the message ends with the fix. |
372
372
  | The model is refused, or not found | The seat's plan or your organization's Copilot policies do not allow that model: pick one the sign-in listed, or ask a Copilot admin to enable it. |
373
373
  | `GitHub Copilot runs through the ForgeQE engine, which is not set up` (or `could not be set up (…)`) | Run `forgeqe setup`; the reason in brackets says what failed. |
374
+ | Copilot is slow, or a run waits on it ("Starting the browser…", "working out the first step…") | `~/.forgeqe/logs/copilot-gateway.log` has a line for each Copilot request: when it started, Copilot's session, when each turn ended, and how long it took. It never holds prompts, replies or tokens. A turn that ends without Copilot saying it is idle (a known Copilot SDK issue) is answered 5 seconds later instead of after the 5-minute limit, and a session the run no longer needs (Stop, an error) is stopped. |
374
375
  | `CERTIFICATE_VERIFY_FAILED` while downloading Copilot's runtime | Your network inspects HTTPS. Update ForgeQE and run `forgeqe setup`, then sign in again: it uses the certificates your computer trusts (see [Company networks](#company-networks)). If it still fails, run `forgeqe doctor` and send its output to IT. |
375
376
 
376
377
  ### Your prompts, skills, and product knowledge
@@ -616,6 +616,7 @@ bun src/index.ts init --global --profile azure-gpt4o
616
616
  <tr><td><code>Model for agent: main, because copilot failed its live check …</code></td><td>ForgeQE used your other model because Copilot did not answer; the message ends with the fix.</td></tr>
617
617
  <tr><td>The model is refused, or not found</td><td>The seat's plan or your organization's Copilot policies do not allow that model: pick one the sign-in listed, or ask a Copilot admin to enable it.</td></tr>
618
618
  <tr><td><code>GitHub Copilot runs through the ForgeQE engine, which is not set up</code> (or <code>could not be set up (…)</code>)</td><td>Run <code>forgeqe setup</code>; the reason in brackets says what failed.</td></tr>
619
+ <tr><td>Copilot is slow, or a run waits on it (&quot;Starting the browser…&quot;, &quot;working out the first step…&quot;)</td><td><code>~/.forgeqe/logs/copilot-gateway.log</code> has a line for each Copilot request: when it started, Copilot's session, when each turn ended, and how long it took. It never holds prompts, replies or tokens. A turn that ends without Copilot saying it is idle (a known Copilot SDK issue) is answered 5 seconds later instead of after the 5-minute limit, and a session the run no longer needs (Stop, an error) is stopped.</td></tr>
619
620
  <tr><td><code>CERTIFICATE_VERIFY_FAILED</code> while downloading Copilot's runtime</td><td>Your network inspects HTTPS. Update ForgeQE and run <code>forgeqe setup</code>, then sign in again: it uses the certificates your computer trusts (see <a href="../HOW_TO_USE.md#company-networks">Company networks</a>). If it still fails, run <code>forgeqe doctor</code> and send its output to IT.</td></tr>
620
621
  </tbody>
621
622
  </table></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "2.3.3",
3
+ "version": "2.3.4",
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": {
@@ -24,12 +24,14 @@ from __future__ import annotations
24
24
  import asyncio
25
25
  import hmac
26
26
  import json
27
+ import logging
27
28
  import os
28
29
  import re
29
30
  import sys
30
31
  import tempfile
31
32
  import time
32
33
  import uuid
34
+ from logging.handlers import RotatingFileHandler
33
35
  from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple
34
36
 
35
37
  from .systemcerts import use_system_certificates
@@ -40,6 +42,10 @@ KEY_ENV = "FORGEQE_COPILOT_GATEWAY_KEY"
40
42
  MAX_BODY = 32 * 1024 * 1024
41
43
  # A screenshot step can take minutes on a slow model; past this the request fails rather than hangs.
42
44
  REQUEST_TIMEOUT_S = 300.0
45
+ # After a turn ends with its reply (or a call of the caller's tools), how long Copilot gets to say it is idle. Copilot
46
+ # sometimes never does (github/copilot-sdk#2853), which held a request, and every agent waiting on it, for the full
47
+ # REQUEST_TIMEOUT_S.
48
+ TURN_END_GRACE_S = 5.0
43
49
  # A tool name as OpenAI and the Copilot SDK take it.
44
50
  TOOL_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
45
51
  MAX_TOOLS = 256
@@ -53,6 +59,34 @@ class GatewayError(Exception):
53
59
  self.message = message
54
60
 
55
61
 
62
+ class CallerGone(Exception):
63
+ """The caller closed the connection (the user pressed Stop): its Copilot session is stopped, nothing is answered."""
64
+
65
+
66
+ # What each request did and how long it took, for when Copilot is slow or stuck: never prompts, replies or tokens.
67
+ _log = logging.getLogger("forgeqe.copilot_gateway")
68
+ _log.propagate = False
69
+
70
+
71
+ def gateway_log(rid: str, text: str) -> None:
72
+ _log.info("%s %s", rid, text)
73
+
74
+
75
+ def open_gateway_log() -> Optional[str]:
76
+ """~/.forgeqe/logs/copilot-gateway.log (FORGEQE_HOME), kept to three files of 1 MB."""
77
+ home = os.environ.get("FORGEQE_HOME") or os.path.join(os.path.expanduser("~"), ".forgeqe")
78
+ path = os.path.join(home, "logs", "copilot-gateway.log")
79
+ try:
80
+ os.makedirs(os.path.dirname(path), exist_ok=True)
81
+ handler = RotatingFileHandler(path, maxBytes=1_000_000, backupCount=2, encoding="utf-8")
82
+ except OSError:
83
+ return None
84
+ handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
85
+ _log.addHandler(handler)
86
+ _log.setLevel(logging.INFO)
87
+ return path
88
+
89
+
56
90
  # ---------------------------------------------------------------------------------------------------------------------
57
91
  # OpenAI chat completions → one Copilot session
58
92
 
@@ -337,11 +371,37 @@ class Copilot:
337
371
  })
338
372
  return out
339
373
 
340
- async def complete(self, req: Dict[str, Any], on_text: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
374
+ async def complete(self, req: Dict[str, Any], on_text: Optional[Callable[[str], None]] = None, gone: Optional["asyncio.Future[Any]"] = None) -> Dict[str, Any]:
341
375
  """The answer to one request. With on_text, the reply's text goes to it as Copilot writes it (not for a JSON
342
- reply, whose code fences are removed once it is whole)."""
376
+ reply, whose code fences are removed once it is whole). `gone` completes when the caller hangs up."""
377
+ rid = uuid.uuid4().hex[:8]
378
+ started = time.monotonic()
379
+ gateway_log(rid, f"start model={req['model']} tools={len(req['tools'])} schema={req['schema'] is not None} stream={bool(on_text)}")
380
+ try:
381
+ out = await self._complete(rid, req, on_text, gone)
382
+ except CallerGone:
383
+ gateway_log(rid, f"caller gone after {time.monotonic() - started:.1f}s: its Copilot session was stopped")
384
+ raise
385
+ except GatewayError as e:
386
+ gateway_log(rid, f"error {e.status} after {time.monotonic() - started:.1f}s: {e.message[:300]}")
387
+ raise
388
+ calls = out["choices"][0]["message"].get("tool_calls") or []
389
+ gateway_log(rid, f"done after {time.monotonic() - started:.1f}s" + (f" calls={','.join(c['function']['name'] for c in calls)}" if calls else ""))
390
+ return out
391
+
392
+ async def _complete(self, rid: str, req: Dict[str, Any], on_text: Optional[Callable[[str], None]], gone: Optional["asyncio.Future[Any]"]) -> Dict[str, Any]:
343
393
  from copilot.generated.rpc import PermissionDecisionReject
344
- from copilot.session_events import AssistantMessageData, AssistantMessageDeltaData, AssistantUsageData, SessionErrorData
394
+ from copilot.session_events import (
395
+ AssistantMessageData,
396
+ AssistantMessageDeltaData,
397
+ AssistantTurnEndData,
398
+ AssistantTurnStartData,
399
+ AssistantUsageData,
400
+ SessionErrorData,
401
+ SessionIdleData,
402
+ )
403
+
404
+ loop = asyncio.get_running_loop()
345
405
 
346
406
  # The text callback while this request streams; None once it is asked for again without streaming.
347
407
  stream: Dict[str, Any] = {"to": None if req["schema"] is not None or req["json_only"] else on_text, "sent": False}
@@ -349,6 +409,27 @@ class Copilot:
349
409
  usage = {"input": 0, "output": 0, "cached": 0}
350
410
  errors: List[str] = []
351
411
  calls: List[Dict[str, Any]] = []
412
+ # The last root reply without tool requests, and the timer that ends the wait when a turn ends without idle.
413
+ turn: Dict[str, Any] = {"final": None, "timer": None, "settled": asyncio.Event()}
414
+
415
+ def on_loop(fn: Callable[[], None]) -> None:
416
+ """The SDK may call on_event from a thread of its own; timers and events belong to the server's loop."""
417
+ try:
418
+ if asyncio.get_running_loop() is loop:
419
+ fn()
420
+ return
421
+ except RuntimeError:
422
+ pass
423
+ loop.call_soon_threadsafe(fn)
424
+
425
+ def settle_soon() -> None:
426
+ if turn["timer"] is None:
427
+ turn["timer"] = loop.call_later(TURN_END_GRACE_S, turn["settled"].set)
428
+
429
+ def unsettle() -> None:
430
+ if turn["timer"] is not None:
431
+ turn["timer"].cancel()
432
+ turn["timer"] = None
352
433
 
353
434
  def on_event(event: Any) -> None:
354
435
  data = getattr(event, "data", None)
@@ -362,12 +443,25 @@ class Copilot:
362
443
  elif stream["to"] and isinstance(data, AssistantMessageDeltaData) and data.delta_content and not data.parent_tool_call_id:
363
444
  stream["sent"] = True
364
445
  stream["to"](data.delta_content)
446
+ elif isinstance(data, AssistantMessageData) and not data.parent_tool_call_id:
447
+ turn["final"] = None if data.tool_requests else event
448
+ elif isinstance(data, AssistantTurnStartData) and not data.parent_tool_call_id:
449
+ on_loop(unsettle)
450
+ elif isinstance(data, AssistantTurnEndData) and not data.parent_tool_call_id:
451
+ gateway_log(rid, "turn ended" + (" with a call of the caller's tools" if calls else " with a reply" if turn["final"] is not None else ""))
452
+ if calls or turn["final"] is not None:
453
+ on_loop(settle_soon)
454
+ elif isinstance(data, SessionIdleData):
455
+ gateway_log(rid, "idle")
365
456
 
366
457
  tried: set = set()
367
458
  reply: Any = None
368
459
  for _ in range(MAX_TOOL_RETRIES + 2):
369
460
  errors.clear()
370
461
  calls.clear()
462
+ on_loop(unsettle)
463
+ turn["final"] = None
464
+ turn["settled"] = asyncio.Event()
371
465
  names = {t["name"] for t in req["tools"]}
372
466
  tools = relay_tools(req["tools"], calls, replaces=self.replaces & names)
373
467
  options: Dict[str, Any] = dict(
@@ -387,15 +481,27 @@ class Copilot:
387
481
  )
388
482
  failure: Optional[str] = None
389
483
  session = None
484
+ # A session that did not finish cleanly is stopped before it is detached: detaching alone leaves Copilot
485
+ # working on it in the background.
486
+ clean = False
390
487
  try:
391
488
  session = await self.client.create_session(**options)
392
- reply = await session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
489
+ gateway_log(rid, f"session {getattr(session, 'session_id', '?')}")
490
+ reply, clean = await self._wait(rid, session, req, turn, gone)
393
491
  except TimeoutError:
394
492
  raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
493
+ except CallerGone:
494
+ raise
395
495
  except Exception as e: # Copilot names a tool it will not take; anything else is reported below.
396
496
  failure = str(e)
397
497
  finally:
498
+ on_loop(unsettle)
398
499
  if session is not None:
500
+ if not clean:
501
+ try:
502
+ await session.abort()
503
+ except Exception:
504
+ pass
399
505
  try:
400
506
  await session.disconnect()
401
507
  except Exception:
@@ -431,6 +537,33 @@ class Copilot:
431
537
  return completion(req["model"], content, usage)
432
538
 
433
539
 
540
+ async def _wait(self, rid: str, session: Any, req: Dict[str, Any], turn: Dict[str, Any], gone: Optional["asyncio.Future[Any]"]) -> Tuple[Any, bool]:
541
+ """
542
+ The reply, and whether Copilot finished cleanly. Copilot normally says it is idle; when a turn has ended with a
543
+ reply or a call of the caller's tools and it does not, the reply is taken TURN_END_GRACE_S later. A caller that
544
+ hangs up ends the wait at once.
545
+ """
546
+ waiter = asyncio.ensure_future(
547
+ session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
548
+ )
549
+ waiter.add_done_callback(lambda t: t.cancelled() or t.exception())
550
+ settled = asyncio.ensure_future(turn["settled"].wait())
551
+ watch = {waiter, settled} | ({gone} if gone is not None else set())
552
+ try:
553
+ done, _ = await asyncio.wait(watch, timeout=REQUEST_TIMEOUT_S + 5, return_when=asyncio.FIRST_COMPLETED)
554
+ finally:
555
+ settled.cancel()
556
+ if waiter in done:
557
+ return waiter.result(), True
558
+ waiter.cancel()
559
+ if gone is not None and gone in done:
560
+ raise CallerGone()
561
+ if settled in done:
562
+ gateway_log(rid, f"the turn ended but Copilot did not say it was idle within {TURN_END_GRACE_S:.0f}s: answered anyway")
563
+ return turn["final"], False
564
+ raise TimeoutError()
565
+
566
+
434
567
  # Copilot's own tools that the agent runtime's tools are named like (bash, glob, grep, edit, …). A caller tool with one
435
568
  # of these names says it replaces Copilot's: the caller's tool is the one offered, and it runs nothing here.
436
569
  BUILTIN_TOOLS = frozenset({"bash", "powershell", "glob", "grep", "view", "edit", "create", "task", "skill", "web_fetch", "think", "update_todo", "report_intent"})
@@ -566,6 +699,13 @@ class Stream:
566
699
  self._event({"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
567
700
 
568
701
 
702
+ async def caller_gone(reader: asyncio.StreamReader) -> None:
703
+ """Completes when the caller closes the connection. Its callers (the agent runtime, the engine's HTTP client) never
704
+ half-close, so the end of what it sends is the caller gone."""
705
+ while await reader.read(4096):
706
+ pass
707
+
708
+
569
709
  async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
570
710
  try:
571
711
  try:
@@ -608,16 +748,24 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cop
608
748
  writer.write(_error(400, "The request body is not JSON."))
609
749
  return
610
750
  req = translate(body)
611
- if req["stream"]:
612
- stream = Stream(writer, req["model"])
613
- try:
614
- await stream.finish(await copilot.complete(req, stream.text))
615
- except GatewayError as e:
616
- await stream.fail(e.status, e.message)
617
- except Exception as e:
618
- await stream.fail(502, f"GitHub Copilot: {type(e).__name__}: {e}"[:2000])
619
- return
620
- writer.write(_response(200, await copilot.complete(req)))
751
+ gone = asyncio.ensure_future(caller_gone(reader))
752
+ try:
753
+ if req["stream"]:
754
+ stream = Stream(writer, req["model"])
755
+ try:
756
+ await stream.finish(await copilot.complete(req, stream.text, gone=gone))
757
+ except GatewayError as e:
758
+ await stream.fail(e.status, e.message)
759
+ except CallerGone:
760
+ raise
761
+ except Exception as e:
762
+ await stream.fail(502, f"GitHub Copilot: {type(e).__name__}: {e}"[:2000])
763
+ return
764
+ writer.write(_response(200, await copilot.complete(req, gone=gone)))
765
+ finally:
766
+ gone.cancel()
767
+ except CallerGone:
768
+ return
621
769
  except GatewayError as e:
622
770
  writer.write(_error(e.status, e.message))
623
771
  except Exception as e: # The SDK's own errors: report them rather than drop the connection.
@@ -640,6 +788,7 @@ async def serve(out: TextIO, copilot: Any = None) -> int:
640
788
  if token.startswith("ghp_"):
641
789
  out.write(json.dumps({"error": "GitHub Copilot does not take classic tokens (ghp_): use a fine-grained token with the Copilot Requests permission."}) + "\n")
642
790
  return 1
791
+ open_gateway_log()
643
792
  try:
644
793
  copilot = Copilot(token or None)
645
794
  await copilot.start()
@@ -654,6 +803,7 @@ async def serve(out: TextIO, copilot: Any = None) -> int:
654
803
  return 1
655
804
  server = await asyncio.start_server(lambda r, w: handle(r, w, copilot, key), host="127.0.0.1", port=0, limit=64 * 1024)
656
805
  port = server.sockets[0].getsockname()[1]
806
+ gateway_log("gateway", f"listening on 127.0.0.1:{port}")
657
807
  out.write(json.dumps({"port": port, "login": getattr(copilot, "login", "")}) + "\n")
658
808
  out.flush()
659
809
  loop = asyncio.get_running_loop()