@capagents/forgeqe 2.3.0 → 2.3.2

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.0",
3
+ "version": "2.3.2",
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
 
@@ -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()
@@ -300,6 +303,14 @@ class Copilot:
300
303
  raise GatewayError(401, "GitHub Copilot is not signed in: run forgeqe auth login copilot, which opens GitHub in your browser.")
301
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.")
302
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
+
303
314
  async def stop(self) -> None:
304
315
  try:
305
316
  await self.client.stop()
@@ -326,9 +337,14 @@ class Copilot:
326
337
  })
327
338
  return out
328
339
 
329
- 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)."""
330
343
  from copilot.generated.rpc import PermissionDecisionReject
331
- 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}
332
348
 
333
349
  usage = {"input": 0, "output": 0, "cached": 0}
334
350
  errors: List[str] = []
@@ -342,42 +358,68 @@ class Copilot:
342
358
  usage["cached"] += data.cache_read_tokens or 0
343
359
  elif isinstance(data, SessionErrorData):
344
360
  errors.append(data.message or data.error_type or "error")
345
-
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:
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)
365
+
366
+ tried: set = set()
367
+ reply: Any = None
368
+ for _ in range(MAX_TOOL_RETRIES + 2):
369
+ errors.clear()
370
+ calls.clear()
371
+ names = {t["name"] for t in req["tools"]}
372
+ tools = relay_tools(req["tools"], calls, replaces=self.replaces & names)
373
+ options: Dict[str, Any] = dict(
374
+ model=req["model"],
375
+ # The caller's prompt is the whole prompt; Copilot's own tools are never offered, only the caller's.
376
+ system_message={"mode": "replace", "content": req["system"] or "You are a helpful assistant."},
377
+ tools=tools or None,
378
+ available_tools=[t.name for t in tools],
379
+ on_permission_request=lambda *_: PermissionDecisionReject(),
380
+ working_directory=self.workdir,
381
+ skip_custom_instructions=True,
382
+ enable_config_discovery=False,
383
+ enable_skills=False,
384
+ enable_session_store=False,
385
+ on_event=on_event,
386
+ **({"streaming": True} if stream["to"] else {}),
387
+ )
388
+ failure: Optional[str] = None
389
+ session = None
374
390
  try:
375
- await session.disconnect()
376
- except Exception:
377
- pass
391
+ 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)
393
+ except TimeoutError:
394
+ raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
395
+ except Exception as e: # Copilot names a tool it will not take; anything else is reported below.
396
+ failure = str(e)
397
+ finally:
398
+ if session is not None:
399
+ try:
400
+ await session.disconnect()
401
+ except Exception:
402
+ pass
403
+ # Copilot reports a clash with its own tools when the session starts or when the turn starts.
404
+ fix = tool_conflict(failure or (errors[-1] if errors and not calls else ""), names)
405
+ if fix and fix not in tried:
406
+ tried.add(fix)
407
+ name, replace = fix
408
+ (self.replaces.add if replace else self.replaces.discard)(name)
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
416
+ if failure:
417
+ raise self.refusal(failure)
418
+ break
419
+ else:
420
+ raise GatewayError(502, "GitHub Copilot kept refusing the names of the caller's tools.")
378
421
  if errors and not calls:
379
- msg = errors[-1]
380
- raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
422
+ raise self.refusal(errors[-1])
381
423
  data = getattr(reply, "data", None)
382
424
  content = (data.content or "") if isinstance(data, AssistantMessageData) else ""
383
425
  if calls:
@@ -389,10 +431,44 @@ class Copilot:
389
431
  return completion(req["model"], content, usage)
390
432
 
391
433
 
392
- def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], override: bool = False) -> List[Any]:
434
+ # Copilot's own tools that the agent runtime's tools are named like (bash, glob, grep, edit, …). A caller tool with one
435
+ # of these names says it replaces Copilot's: the caller's tool is the one offered, and it runs nothing here.
436
+ BUILTIN_TOOLS = frozenset({"bash", "powershell", "glob", "grep", "view", "edit", "create", "task", "skill", "web_fetch", "think", "update_todo", "report_intent"})
437
+ MAX_TOOL_RETRIES = 8
438
+
439
+ _CONFLICT = re.compile(r'(?:external )?tool "([A-Za-z0-9_-]{1,64})" conflicts with a built-in tool', re.I)
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)
441
+
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
+
451
+ def tool_conflict(message: str, names: set) -> Optional[Tuple[str, bool]]:
452
+ """
453
+ From Copilot's error, the caller tool to retry with: (name, True) when it clashes with a built-in and must replace
454
+ it, (name, False) when Copilot has no built-in of that name to replace. None for any other error.
455
+ """
456
+ if not message:
457
+ return None
458
+ m = _CONFLICT.search(message)
459
+ if m and m.group(1) in names:
460
+ return m.group(1), True
461
+ m = _NO_BUILTIN.search(message)
462
+ if m and m.group(1) in names:
463
+ return m.group(1), False
464
+ return None
465
+
466
+
467
+ def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], override: bool = False, replaces: Optional[set] = None) -> List[Any]:
393
468
  """
394
469
  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.
470
+ the turn, so the call goes back to the caller as OpenAI tool_calls. A tool in `replaces` (or every tool, with
471
+ `override`) replaces Copilot's built-in tool of the same name.
396
472
  """
397
473
  if not specs:
398
474
  return []
@@ -408,7 +484,7 @@ def relay_tools(specs: List[Dict[str, Any]], calls: List[Dict[str, Any]], overri
408
484
  return ToolResult(text_result_for_llm="The caller runs this tool and sends its result in the next message.")
409
485
 
410
486
  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)
487
+ 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
488
  for t in specs
413
489
  ]
414
490
 
@@ -435,6 +511,61 @@ def _error(status: int, message: str) -> bytes:
435
511
  return _response(status, {"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
436
512
 
437
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
+
438
569
  async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, copilot: Any, key: str) -> None:
439
570
  try:
440
571
  try:
@@ -477,8 +608,16 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cop
477
608
  writer.write(_error(400, "The request body is not JSON."))
478
609
  return
479
610
  req = translate(body)
480
- done = await copilot.complete(req)
481
- 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)))
482
621
  except GatewayError as e:
483
622
  writer.write(_error(e.status, e.message))
484
623
  except Exception as e: # The SDK's own errors: report them rather than drop the connection.