@capagents/forgeqe 2.3.1 → 2.3.3

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.
@@ -598,7 +598,7 @@ bun src/index.ts init --global --profile azure-gpt4o
598
598
  <p>Copilot runs one SDK session per model request, with none of Copilot's own tools and your request's system prompt in place of Copilot's. The agent's tools are offered to the model as the session's only tools; when it calls one, the gateway ends the turn and hands the call back to ForgeQE's agent runtime, which runs it (with the same permissions as on any other model) and sends the result with the next request. The gateway itself runs nothing.</p>
599
599
  <p><strong>With Copilot and a key-based model both in <code>llms.json</code></strong>, Copilot comes first: a sign-in makes it the default, and without a <code>&quot;default&quot;</code> it is the active profile. Before a model is used, ForgeQE checks that it works (its sign-in or key, and one live call, kept for a day once it passes). If Copilot does not work (not signed in, the Copilot SDK missing, the model not on your seat), the other model is used and ForgeQE says why and how to fix Copilot; if a key-based model you made the default does not work, Copilot is tried first. A model that fails its check is never used, and when none works the error lists each one with what to do. <code>--profile</code> turns this off: the model you name is used as it is. Each request is billed to the seat (GitHub AI credits or premium requests, by your plan), and GitHub may rate-limit frequent automated requests. If a model is refused, check that your organization's Copilot policies allow it. ForgeQE shows no cost for a <code>github_copilot</code> profile unless you give it a <code>price</code>.</p>
600
600
  <p><strong>In pipelines.</strong> <code>forgeqe suite ... --no-code</code> uses only the browser engine. Set <code>COPILOT_GITHUB_TOKEN</code> to a fine-grained token with the Copilot Requests permission, stored as a pipeline secret. GitHub also documents the workflow's own <code>GITHUB_TOKEN</code> with <code>permissions: copilot-requests: write</code> for organization repositories whose Copilot policy allows it (<a href="https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/server-to-server-tokens" rel="noopener">server tokens</a>). A Copilot seat belongs to one person: prefer an organization-billed token over signing a shared pipeline in to someone's seat.</p>
601
- <p>GitHub Models, which ForgeQE used for the engine before, was <a href="https://github.blog/changelog/2026-07-30-github-models-is-now-retired/" rel="noopener">retired by GitHub</a> on July 30, 2026. A profile that still has <code>engine_model: &quot;openai/...&quot;</code> or <code>github_org</code> needs updating: drop <code>github_org</code>, and use a Copilot model id.</p>
601
+ <p>GitHub Models, which ForgeQE used for the engine before, was <a href="https://github.blog/changelog/2026-07-30-github-models-is-now-retired/" rel="noopener">retired by GitHub</a> on July 30, 2026. A profile that still has <code>engine_model: &quot;openai/...&quot;</code> or <code>github_org</code> needs updating: drop <code>github_org</code>, and use a Copilot model id. A profile whose endpoint is GitHub Models (<code>models.github.ai</code>) is never called: remove it from <code>llms.json</code>. Copilot needs no <code>GITHUB_TOKEN</code> or <code>GH_TOKEN</code>; ForgeQE does not pass them to Copilot.</p>
602
602
  <p><strong>When something goes wrong.</strong></p>
603
603
  <div class="tbl"><table>
604
604
  <thead>
@@ -609,6 +609,7 @@ bun src/index.ts init --global --profile azure-gpt4o
609
609
  <tr><td><code>The GitHub sign-in did not finish</code></td><td>The browser sign-in was cancelled or denied. Run <code>forgeqe auth login copilot</code> again; with SAML single sign-on, authorize your organization when GitHub asks.</td></tr>
610
610
  <tr><td><code>GitHub Copilot does not take classic tokens (ghp_)</code></td><td>Sign in in the browser instead, or make a fine-grained token with the Copilot Requests permission, owned by your personal account.</td></tr>
611
611
  <tr><td><code>GitHub Copilot did not accept the token</code></td><td>The token has no Copilot Requests permission, has expired, or the account has no Copilot seat.</td></tr>
612
+ <tr><td><code>GitHub Copilot refused the sign-in</code> or <code>refused the token</code> (<code>Authorization error. Your credentials may be expired or invalid</code>)</td><td>Copilot received the credential and would not take it. A token wins over the browser sign-in: if <code>COPILOT_GITHUB_TOKEN</code> is set, or you once pasted a token, that is what was refused; remove it and run <code>forgeqe auth login copilot</code> in the browser. Otherwise sign in again. If it still fails and your seat comes from your company, a GitHub admin must allow Copilot CLI in the Copilot policies: the Copilot SDK runs on it.</td></tr>
612
613
  <tr><td><code>… must be a Copilot model id</code>, <code>engine_model must be a Copilot model id</code></td><td>Fix that field in <code>llms.json</code>: the sign-in lists the seat's models. A GitHub Models id such as <code>openai/gpt-4.1</code> is not a Copilot id; use <code>gpt-4.1</code>.</td></tr>
613
614
  <tr><td><code>No model in llms.json can … right now</code></td><td>Each model is listed with why it cannot be used and the fix. <code>forgeqe doctor</code> checks everything at once.</td></tr>
614
615
  <tr><td><code>GitHub's Copilot SDK is not installed in the ForgeQE engine</code></td><td>Run <code>forgeqe setup</code>. It installs the SDK from pypi.org; if that is blocked, <code>forgeqe doctor</code> says what to ask IT for.</td></tr>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capagents/forgeqe",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
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": {
@@ -30,7 +30,7 @@ import sys
30
30
  import tempfile
31
31
  import time
32
32
  import uuid
33
- from typing import Any, Dict, List, Optional, TextIO, Tuple
33
+ from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple
34
34
 
35
35
  from .systemcerts import use_system_certificates
36
36
 
@@ -303,6 +303,14 @@ class Copilot:
303
303
  raise GatewayError(401, "GitHub Copilot is not signed in: run forgeqe auth login copilot, which opens GitHub in your browser.")
304
304
  raise GatewayError(401, "GitHub Copilot did not accept the token: use a fine-grained token with the Copilot Requests permission, for an account with a Copilot seat.")
305
305
 
306
+ def refusal(self, message: str) -> "GatewayError":
307
+ """Copilot's error as the gateway reports it. A refused sign-in says whose, and whether it was a token."""
308
+ if not signin_refused(message):
309
+ return GatewayError(502, f"GitHub Copilot: {message}"[:2000])
310
+ what = "the sign-in" if self.signed_in_user else "the token"
311
+ who = f" of {self.login}" if getattr(self, "login", "") else ""
312
+ return GatewayError(401, f"GitHub Copilot refused {what}{who}: {message}"[:2000])
313
+
306
314
  async def stop(self) -> None:
307
315
  try:
308
316
  await self.client.stop()
@@ -329,9 +337,14 @@ class Copilot:
329
337
  })
330
338
  return out
331
339
 
332
- async def complete(self, req: Dict[str, Any]) -> Dict[str, Any]:
340
+ async def complete(self, req: Dict[str, Any], on_text: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
341
+ """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)."""
333
343
  from copilot.generated.rpc import PermissionDecisionReject
334
- from copilot.session_events import AssistantMessageData, AssistantUsageData, SessionErrorData
344
+ from copilot.session_events import AssistantMessageData, AssistantMessageDeltaData, AssistantUsageData, SessionErrorData
345
+
346
+ # The text callback while this request streams; None once it is asked for again without streaming.
347
+ stream: Dict[str, Any] = {"to": None if req["schema"] is not None or req["json_only"] else on_text, "sent": False}
335
348
 
336
349
  usage = {"input": 0, "output": 0, "cached": 0}
337
350
  errors: List[str] = []
@@ -345,10 +358,14 @@ class Copilot:
345
358
  usage["cached"] += data.cache_read_tokens or 0
346
359
  elif isinstance(data, SessionErrorData):
347
360
  errors.append(data.message or data.error_type or "error")
361
+ # A sub-agent's text is not the reply.
362
+ elif stream["to"] and isinstance(data, AssistantMessageDeltaData) and data.delta_content and not data.parent_tool_call_id:
363
+ stream["sent"] = True
364
+ stream["to"](data.delta_content)
348
365
 
349
366
  tried: set = set()
350
367
  reply: Any = None
351
- for _ in range(MAX_TOOL_RETRIES + 1):
368
+ for _ in range(MAX_TOOL_RETRIES + 2):
352
369
  errors.clear()
353
370
  calls.clear()
354
371
  names = {t["name"] for t in req["tools"]}
@@ -366,6 +383,7 @@ class Copilot:
366
383
  enable_skills=False,
367
384
  enable_session_store=False,
368
385
  on_event=on_event,
386
+ **({"streaming": True} if stream["to"] else {}),
369
387
  )
370
388
  failure: Optional[str] = None
371
389
  session = None
@@ -389,14 +407,19 @@ class Copilot:
389
407
  name, replace = fix
390
408
  (self.replaces.add if replace else self.replaces.discard)(name)
391
409
  continue
410
+ # A streamed request that failed before any text: asked once more the way that worked before streaming, so
411
+ # streaming is never what stops Copilot. A refused sign-in would fail the same way, so it is not asked again.
412
+ problem = failure or (errors[-1] if errors and not calls else "")
413
+ if problem and stream["to"] and not stream["sent"] and not signin_refused(problem):
414
+ stream["to"] = None
415
+ continue
392
416
  if failure:
393
- raise GatewayError(502, f"GitHub Copilot: {failure}"[:2000])
417
+ raise self.refusal(failure)
394
418
  break
395
419
  else:
396
420
  raise GatewayError(502, "GitHub Copilot kept refusing the names of the caller's tools.")
397
421
  if errors and not calls:
398
- msg = errors[-1]
399
- raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
422
+ raise self.refusal(errors[-1])
400
423
  data = getattr(reply, "data", None)
401
424
  content = (data.content or "") if isinstance(data, AssistantMessageData) else ""
402
425
  if calls:
@@ -417,6 +440,14 @@ _CONFLICT = re.compile(r'(?:external )?tool "([A-Za-z0-9_-]{1,64})" conflicts wi
417
440
  _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
441
 
419
442
 
443
+ _SIGNIN_REFUSED = re.compile(r"authori[sz]ation error|credentials may be expired|bad credentials|unauthori[sz]ed|not authenticated|no github oauth token|\b401\b", re.I)
444
+
445
+
446
+ def signin_refused(message: str) -> bool:
447
+ """Whether Copilot's error says it would not take the sign-in or token (as opposed to the model or the request)."""
448
+ return bool(_SIGNIN_REFUSED.search(message or ""))
449
+
450
+
420
451
  def tool_conflict(message: str, names: set) -> Optional[Tuple[str, bool]]:
421
452
  """
422
453
  From Copilot's error, the caller tool to retry with: (name, True) when it clashes with a built-in and must replace
@@ -480,6 +511,61 @@ def _error(status: int, message: str) -> bytes:
480
511
  return _response(status, {"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
481
512
 
482
513
 
514
+ class Stream:
515
+ """
516
+ A streaming answer sent as Copilot writes it. The head goes out with the first piece of text, so a request that
517
+ fails before any text still gets a plain HTTP error; one that fails later ends with an error event.
518
+ """
519
+
520
+ def __init__(self, writer: Any, model: str) -> None:
521
+ self.writer = writer
522
+ self.base = {"id": f"chatcmpl-{uuid.uuid4().hex}", "object": "chat.completion.chunk", "created": int(time.time()), "model": model}
523
+ self.sent = ""
524
+ self.loop = asyncio.get_running_loop()
525
+
526
+ def text(self, delta: str) -> None:
527
+ """Called by the SDK, maybe from a thread of its own: the write happens on the server's loop."""
528
+ try:
529
+ on_loop = asyncio.get_running_loop() is self.loop
530
+ except RuntimeError:
531
+ on_loop = False
532
+ if on_loop:
533
+ self._write(delta)
534
+ else:
535
+ self.loop.call_soon_threadsafe(self._write, delta)
536
+
537
+ def _write(self, delta: str) -> None:
538
+ if not self.sent:
539
+ self.writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n")
540
+ role = {} if self.sent else {"role": "assistant"}
541
+ self.sent += delta
542
+ self._event({**self.base, "choices": [{"index": 0, "delta": {**role, "content": delta}, "finish_reason": None}]})
543
+
544
+ def _event(self, payload: Any) -> None:
545
+ self.writer.write(f"data: {json.dumps(payload)}\n\n".encode())
546
+
547
+ async def finish(self, done: Dict[str, Any]) -> None:
548
+ await asyncio.sleep(0) # text the SDK handed over from its own thread is written first
549
+ if not self.sent:
550
+ self.writer.write(_stream(done))
551
+ return
552
+ message = done["choices"][0]["message"]
553
+ content = message.get("content") or ""
554
+ # Only what was not sent yet: the final text normally is exactly what streamed.
555
+ rest = content[len(self.sent):] if content.startswith(self.sent) else ""
556
+ for c in chunks({**done, **self.base, "choices": [{**done["choices"][0], "message": {**message, "content": rest}}]}):
557
+ c["choices"] = [{**ch, "delta": {k: v for k, v in ch["delta"].items() if k != "role" and (k != "content" or v)}} for ch in c["choices"]]
558
+ self._event(c)
559
+ self.writer.write(b"data: [DONE]\n\n")
560
+
561
+ async def fail(self, status: int, message: str) -> None:
562
+ await asyncio.sleep(0)
563
+ if not self.sent:
564
+ self.writer.write(_error(status, message))
565
+ return
566
+ self._event({"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
567
+
568
+
483
569
  async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
484
570
  try:
485
571
  try:
@@ -522,8 +608,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cop
522
608
  writer.write(_error(400, "The request body is not JSON."))
523
609
  return
524
610
  req = translate(body)
525
- done = await copilot.complete(req)
526
- writer.write(_stream(done) if req["stream"] else _response(200, done))
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)))
527
621
  except GatewayError as e:
528
622
  writer.write(_error(e.status, e.message))
529
623
  except Exception as e: # The SDK's own errors: report them rather than drop the connection.
@@ -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)