@capagents/forgeqe 2.3.2 → 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 +1 -0
- package/dist/forgeqe.js +852 -852
- package/docs/user-guide.html +1 -0
- package/package.json +1 -1
- package/py/forgeqe_engine/copilot_gateway.py +164 -14
- package/py/forgeqe_engine/service.py +10 -0
package/docs/user-guide.html
CHANGED
|
@@ -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 ("Starting the browser…", "working out the first step…")</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
|
+
"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
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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()
|
|
@@ -1002,6 +1002,8 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1002
1002
|
llm = _build_llm(req.llm)
|
|
1003
1003
|
except Exception as e:
|
|
1004
1004
|
return {"success": False, "error": str(e), "summary": ""}
|
|
1005
|
+
_llm_cfg = req.llm if isinstance(req.llm, dict) else {}
|
|
1006
|
+
model_label = str(_llm_cfg.get("deployment") or _llm_cfg.get("model") or "the model")
|
|
1005
1007
|
llm_calls: List[Tuple[float, float]] = []
|
|
1006
1008
|
_time_llm_calls(llm, llm_calls, lambda c: emit({"type": "call", **c}))
|
|
1007
1009
|
use_prompts(req.prompts)
|
|
@@ -1027,6 +1029,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1027
1029
|
{
|
|
1028
1030
|
"type": "log",
|
|
1029
1031
|
"level": "info",
|
|
1032
|
+
"progress": True,
|
|
1030
1033
|
"text": f"ForgeQE engine · {browser_connection_label(browser_provider, browser_kw, browser_remote)} · {'headless' if headless else 'visible'}"
|
|
1031
1034
|
+ (f" · opening {normalized_initial}" if normalized_initial else " · agent picks the site"),
|
|
1032
1035
|
}
|
|
@@ -1514,7 +1517,13 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1514
1517
|
# Remote grids stop taking input on a tab after a password form is submitted: see FreshTabAfterPassword.
|
|
1515
1518
|
fresh_tab = FreshTabAfterPassword() if browser_provider != "local" else None
|
|
1516
1519
|
|
|
1520
|
+
browser_open: List[bool] = []
|
|
1521
|
+
|
|
1517
1522
|
async def _on_step_start(agent_ref: Any) -> None:
|
|
1523
|
+
if not browser_open:
|
|
1524
|
+
# Once: a wait after this line is the model's; one before it is the browser's.
|
|
1525
|
+
browser_open.append(True)
|
|
1526
|
+
emit({"type": "log", "level": "info", "progress": True, "text": f"The browser is open; {model_label} is working out the first step…"})
|
|
1518
1527
|
if not collected_steps:
|
|
1519
1528
|
await diag.start(browser)
|
|
1520
1529
|
if overlay is not None and not collected_steps:
|
|
@@ -1529,6 +1538,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1529
1538
|
return await agent.run(max_steps=step_cap, on_step_start=_on_step_start, on_step_end=_on_step_end)
|
|
1530
1539
|
return await agent.run(on_step_start=_on_step_start, on_step_end=_on_step_end)
|
|
1531
1540
|
|
|
1541
|
+
emit({"type": "log", "level": "info", "progress": True, "text": "Opening the browser…"})
|
|
1532
1542
|
run_task = asyncio.create_task(_run_agent())
|
|
1533
1543
|
cancel_wait = asyncio.create_task(cancel_event.wait())
|
|
1534
1544
|
done, _pending = await asyncio.wait({run_task, cancel_wait}, return_when=asyncio.FIRST_COMPLETED, timeout=timeout_sec)
|