@capagents/forgeqe 2.2.10 → 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.
@@ -1,10 +1,14 @@
1
1
  """
2
2
  GitHub Copilot as an OpenAI-style chat endpoint on this machine, through GitHub's Copilot SDK.
3
3
 
4
- Copilot has no model API for other tools: the SDK runs Copilot "sessions" on the user's seat. The browser engine and
5
- the desktop agent speak OpenAI chat completions, so this gateway turns each request into one SDK session with no
6
- tools of its own (the system prompt replaced, screenshots attached, a JSON schema passed on) and answers in the
7
- OpenAI shape.
4
+ Copilot has no model API for other tools: the SDK runs Copilot "sessions" on the user's seat. The browser engine, the
5
+ desktop agent and the agent runtime (ForgeQE's agent and Scribe) speak OpenAI chat completions, so this gateway turns
6
+ each request into one SDK session with none of Copilot's own tools (the system prompt replaced, screenshots attached, a
7
+ JSON schema passed on) and answers in the OpenAI shape, streamed when the caller asks.
8
+
9
+ The caller's tools become the session's only tools, each one terminal: when the model calls one, the SDK hands the call
10
+ to the gateway, which records it, ends the turn and returns it as OpenAI tool_calls. The caller runs the tool and sends
11
+ its result in the next request's history, as with any OpenAI endpoint. The gateway runs nothing itself.
8
12
 
9
13
  python -m forgeqe_engine copilot-gateway
10
14
  env COPILOT_GITHUB_TOKEN a fine-grained token with Copilot Requests; without it, the signed-in user
@@ -21,6 +25,7 @@ import asyncio
21
25
  import hmac
22
26
  import json
23
27
  import os
28
+ import re
24
29
  import sys
25
30
  import tempfile
26
31
  import time
@@ -35,6 +40,9 @@ KEY_ENV = "FORGEQE_COPILOT_GATEWAY_KEY"
35
40
  MAX_BODY = 32 * 1024 * 1024
36
41
  # A screenshot step can take minutes on a slow model; past this the request fails rather than hangs.
37
42
  REQUEST_TIMEOUT_S = 300.0
43
+ # A tool name as OpenAI and the Copilot SDK take it.
44
+ TOOL_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
45
+ MAX_TOOLS = 256
38
46
  LOCAL_ORIGINS = ("http://127.0.0.1", "http://localhost", "https://127.0.0.1", "https://localhost")
39
47
 
40
48
 
@@ -68,23 +76,52 @@ def _data_url(url: str) -> Optional[Tuple[str, str]]:
68
76
  return mime, data
69
77
 
70
78
 
79
+ def _text(content: Any) -> str:
80
+ return "\n".join(str(p.get("text") or "") for p in _parts(content) if p.get("type") == "text")
81
+
82
+
83
+ def _tools(body: Dict[str, Any]) -> List[Dict[str, Any]]:
84
+ """The caller's function tools as {name, description, parameters}; [] when it sent none or tool_choice is none."""
85
+ raw = body.get("tools")
86
+ if body.get("functions"):
87
+ raise GatewayError(400, "The legacy functions field is not supported: send tools.")
88
+ if raw is None or body.get("tool_choice") == "none":
89
+ return []
90
+ if not isinstance(raw, list) or len(raw) > MAX_TOOLS:
91
+ raise GatewayError(400, f"tools must be a list of at most {MAX_TOOLS} functions.")
92
+ out: List[Dict[str, Any]] = []
93
+ for t in raw:
94
+ fn = t.get("function") if isinstance(t, dict) and t.get("type") == "function" else None
95
+ if not isinstance(fn, dict):
96
+ raise GatewayError(400, "Only function tools are supported.")
97
+ name = str(fn.get("name") or "")
98
+ if not TOOL_NAME.match(name):
99
+ raise GatewayError(400, f"Tool name {name[:80]!r} is not a valid function name.")
100
+ params = fn.get("parameters")
101
+ if params is not None and not isinstance(params, dict):
102
+ raise GatewayError(400, f"Tool {name}'s parameters must be a JSON schema object.")
103
+ out.append({"name": name, "description": str(fn.get("description") or ""), "parameters": params or {"type": "object", "properties": {}}})
104
+ if len({t["name"] for t in out}) != len(out):
105
+ raise GatewayError(400, "Tool names must be unique.")
106
+ return out
107
+
108
+
71
109
  def translate(body: Dict[str, Any]) -> Dict[str, Any]:
72
- """The session a chat completions request becomes: {model, system, prompt, attachments, schema, json_only}."""
110
+ """The session a chat completions request becomes: {model, system, prompt, attachments, schema, json_only, tools, stream}."""
73
111
  if not isinstance(body, dict):
74
112
  raise GatewayError(400, "The request body must be a JSON object.")
75
113
  model = str(body.get("model") or "").strip()
76
114
  if not model:
77
115
  raise GatewayError(400, "The request names no model.")
78
- if body.get("stream"):
79
- raise GatewayError(400, "Streaming is not supported by ForgeQE's Copilot gateway.")
80
- if body.get("tools") or body.get("functions"):
81
- raise GatewayError(400, "Tool calls are not supported by ForgeQE's Copilot gateway yet.")
116
+ tools = _tools(body)
82
117
  messages = body.get("messages")
83
118
  if not isinstance(messages, list) or not messages:
84
119
  raise GatewayError(400, "The request has no messages.")
85
120
 
86
121
  system: List[str] = []
87
122
  turns: List[Tuple[str, List[Dict[str, Any]]]] = []
123
+ # Tool results name their call by id; the call itself, in an earlier assistant message, names the tool.
124
+ called: Dict[str, str] = {}
88
125
  for m in messages:
89
126
  if not isinstance(m, dict):
90
127
  continue
@@ -92,9 +129,21 @@ def translate(body: Dict[str, Any]) -> Dict[str, Any]:
92
129
  parts = _parts(m.get("content"))
93
130
  if role in ("system", "developer"):
94
131
  system.extend(str(p.get("text") or "") for p in parts if p.get("type") == "text")
132
+ elif role == "assistant" and isinstance(m.get("tool_calls"), list):
133
+ for c in m["tool_calls"]:
134
+ fn = c.get("function") if isinstance(c, dict) else None
135
+ if isinstance(fn, dict):
136
+ cid = str(c.get("id") or "")[:64]
137
+ name = str(fn.get("name") or "")
138
+ called[cid] = name if TOOL_NAME.match(name) else "unknown"
139
+ parts = parts + [{"type": "text", "text": f"[called tool {called[cid]} (call {cid}) with {fn.get('arguments') or '{}'}]"}]
140
+ turns.append((role, parts))
141
+ elif role == "tool":
142
+ cid = str(m.get("tool_call_id") or "")[:64]
143
+ turns.append((f"tool_result name={called.get(cid, 'unknown')} call={cid}", [{"type": "text", "text": _text(m.get("content"))}]))
95
144
  else:
96
145
  turns.append((role, parts))
97
- if not turns:
146
+ if not any(role == "user" for role, _ in turns):
98
147
  raise GatewayError(400, "The request has no user message.")
99
148
 
100
149
  # Only the latest message's images go to the model; the browser agent sends the current screenshot there.
@@ -115,10 +164,18 @@ def translate(body: Dict[str, Any]) -> Dict[str, Any]:
115
164
  else:
116
165
  texts.append("[earlier image omitted]")
117
166
  text = "\n".join(t for t in texts if t)
118
- lines.append(text if len(turns) == 1 else f"<{role}>\n{text}\n</{role}>")
167
+ tag = role.split(" ", 1)[0]
168
+ lines.append(text if len(turns) == 1 else f"<{role}>\n{text}\n</{tag}>")
119
169
  prompt = "\n\n".join(lines)
120
170
  if len(turns) > 1:
121
171
  prompt = "The conversation so far, oldest first. Reply as the assistant to the last message.\n\n" + prompt
172
+ if tools:
173
+ prompt += "\n\nTo use a tool, call it through the tool interface; never write a tool call as text."
174
+ choice = body.get("tool_choice")
175
+ if choice == "required":
176
+ prompt += " Call at least one tool now."
177
+ elif isinstance(choice, dict) and isinstance(choice.get("function"), dict):
178
+ prompt += f" Call the tool {choice['function'].get('name')} now."
122
179
 
123
180
  schema = None
124
181
  json_only = False
@@ -131,19 +188,31 @@ def translate(body: Dict[str, Any]) -> Dict[str, Any]:
131
188
  elif fmt.get("type") == "json_object":
132
189
  json_only = True
133
190
  prompt += "\n\nReply with a single JSON object and nothing else."
134
- return {"model": model, "system": "\n\n".join(s for s in system if s), "prompt": prompt, "attachments": attachments, "schema": schema, "json_only": json_only}
191
+ return {
192
+ "model": model,
193
+ "system": "\n\n".join(s for s in system if s),
194
+ "prompt": prompt,
195
+ "attachments": attachments,
196
+ "schema": schema,
197
+ "json_only": json_only,
198
+ "tools": tools,
199
+ "stream": bool(body.get("stream")),
200
+ }
135
201
 
136
202
 
137
- def completion(model: str, content: str, usage: Dict[str, int]) -> Dict[str, Any]:
138
- """An OpenAI chat.completion object."""
203
+ def completion(model: str, content: str, usage: Dict[str, int], tool_calls: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
204
+ """An OpenAI chat.completion object; with tool_calls, the calls the model made and finish_reason tool_calls."""
139
205
  prompt_tokens = int(usage.get("input", 0))
140
206
  completion_tokens = int(usage.get("output", 0))
207
+ message: Dict[str, Any] = {"role": "assistant", "content": content}
208
+ if tool_calls:
209
+ message["tool_calls"] = [{"id": c["id"], "type": "function", "function": {"name": c["name"], "arguments": c["arguments"]}} for c in tool_calls]
141
210
  out: Dict[str, Any] = {
142
211
  "id": f"chatcmpl-{uuid.uuid4().hex}",
143
212
  "object": "chat.completion",
144
213
  "created": int(time.time()),
145
214
  "model": model,
146
- "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
215
+ "choices": [{"index": 0, "message": message, "finish_reason": "tool_calls" if tool_calls else "stop"}],
147
216
  "usage": {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, "total_tokens": prompt_tokens + completion_tokens},
148
217
  }
149
218
  if usage.get("cached"):
@@ -151,6 +220,21 @@ def completion(model: str, content: str, usage: Dict[str, int]) -> Dict[str, Any
151
220
  return out
152
221
 
153
222
 
223
+ def chunks(done: Dict[str, Any]) -> List[Dict[str, Any]]:
224
+ """A finished chat.completion as the chat.completion.chunk events a streaming caller expects, usage last."""
225
+ choice = done["choices"][0]
226
+ message = choice["message"]
227
+ base = {"id": done["id"], "object": "chat.completion.chunk", "created": done["created"], "model": done["model"]}
228
+ delta: Dict[str, Any] = {"role": "assistant", "content": message.get("content") or ""}
229
+ if message.get("tool_calls"):
230
+ delta["tool_calls"] = [{"index": i, **c} for i, c in enumerate(message["tool_calls"])]
231
+ return [
232
+ {**base, "choices": [{"index": 0, "delta": delta, "finish_reason": None}]},
233
+ {**base, "choices": [{"index": 0, "delta": {}, "finish_reason": choice["finish_reason"]}]},
234
+ {**base, "choices": [], "usage": done["usage"]},
235
+ ]
236
+
237
+
154
238
  def strip_fences(text: str) -> str:
155
239
  """```json … ``` → the JSON inside, for models that wrap structured replies in a code fence."""
156
240
  t = text.strip()
@@ -206,6 +290,9 @@ class Copilot:
206
290
  self.workdir = tempfile.mkdtemp(prefix="forgeqe-copilot-")
207
291
  self.signed_in_user = not token
208
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)
209
296
 
210
297
  async def start(self) -> None:
211
298
  await self.client.start()
@@ -225,8 +312,21 @@ class Copilot:
225
312
  async def models(self) -> List[Dict[str, Any]]:
226
313
  out = []
227
314
  for m in await self.client.list_models():
228
- supports = getattr(getattr(m, "capabilities", None), "supports", None)
229
- out.append({"id": m.id, "object": "model", "owned_by": "github-copilot", "vision": bool(getattr(supports, "vision", False))})
315
+ caps = getattr(m, "capabilities", None)
316
+ supports = getattr(caps, "supports", None)
317
+ limits = getattr(caps, "limits", None)
318
+ policy = getattr(getattr(m, "policy", None), "state", None)
319
+ multiplier = getattr(getattr(m, "billing", None), "multiplier", None)
320
+ out.append({
321
+ "id": m.id,
322
+ "object": "model",
323
+ "owned_by": "github-copilot",
324
+ "vision": bool(getattr(supports, "vision", False)),
325
+ # enabled, disabled (the seat's policy turns it off) or unconfigured (enable it in GitHub's Copilot settings).
326
+ "policy": str(policy) if policy else "enabled",
327
+ **({"multiplier": float(multiplier)} if isinstance(multiplier, (int, float)) else {}),
328
+ **({"context": int(limits.max_prompt_tokens)} if getattr(limits, "max_prompt_tokens", None) else {}),
329
+ })
230
330
  return out
231
331
 
232
332
  async def complete(self, req: Dict[str, Any]) -> Dict[str, Any]:
@@ -235,6 +335,7 @@ class Copilot:
235
335
 
236
336
  usage = {"input": 0, "output": 0, "cached": 0}
237
337
  errors: List[str] = []
338
+ calls: List[Dict[str, Any]] = []
238
339
 
239
340
  def on_event(event: Any) -> None:
240
341
  data = getattr(event, "data", None)
@@ -245,40 +346,118 @@ class Copilot:
245
346
  elif isinstance(data, SessionErrorData):
246
347
  errors.append(data.message or data.error_type or "error")
247
348
 
248
- session = await self.client.create_session(
249
- model=req["model"],
250
- # The caller's prompt is the whole prompt; without one the session still gets no tools.
251
- system_message={"mode": "replace", "content": req["system"] or "You are a helpful assistant."},
252
- available_tools=[],
253
- on_permission_request=lambda *_: PermissionDecisionReject(),
254
- working_directory=self.workdir,
255
- skip_custom_instructions=True,
256
- enable_config_discovery=False,
257
- enable_skills=False,
258
- enable_session_store=False,
259
- on_event=on_event,
260
- )
261
- try:
262
- reply = await session.send_and_wait(req["prompt"], attachments=req["attachments"] or None, response_schema=req["schema"], timeout=REQUEST_TIMEOUT_S)
263
- except TimeoutError:
264
- raise GatewayError(504, f"GitHub Copilot did not answer within {int(REQUEST_TIMEOUT_S)} seconds.")
265
- 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
266
372
  try:
267
- await session.disconnect()
268
- except Exception:
269
- pass
270
- if errors:
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.")
397
+ if errors and not calls:
271
398
  msg = errors[-1]
272
399
  raise GatewayError(401 if "auth" in msg.lower() or "401" in msg else 502, f"GitHub Copilot: {msg}")
273
400
  data = getattr(reply, "data", None)
401
+ content = (data.content or "") if isinstance(data, AssistantMessageData) else ""
402
+ if calls:
403
+ return completion(req["model"], content, usage, calls)
274
404
  if not isinstance(data, AssistantMessageData):
275
405
  raise GatewayError(502, "GitHub Copilot sent no reply.")
276
- content = data.content or ""
277
406
  if req["schema"] is not None or req["json_only"]:
278
407
  content = strip_fences(content)
279
408
  return completion(req["model"], content, usage)
280
409
 
281
410
 
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]:
437
+ """
438
+ The caller's tools as Copilot SDK tools that run nothing: each records the call in `calls` and, being terminal, ends
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.
441
+ """
442
+ if not specs:
443
+ return []
444
+ from copilot.tools import Tool, ToolResult
445
+
446
+ def handler(invocation: Any) -> Any:
447
+ args = invocation.arguments
448
+ calls.append({
449
+ "id": invocation.tool_call_id or f"call_{uuid.uuid4().hex[:24]}",
450
+ "name": invocation.tool_name,
451
+ "arguments": args if isinstance(args, str) else json.dumps(args if args is not None else {}),
452
+ })
453
+ return ToolResult(text_result_for_llm="The caller runs this tool and sends its result in the next message.")
454
+
455
+ return [
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()))
457
+ for t in specs
458
+ ]
459
+
460
+
282
461
  # ---------------------------------------------------------------------------------------------------------------------
283
462
  # A small HTTP/1.1 server: one request per connection
284
463
 
@@ -290,6 +469,13 @@ def _response(status: int, payload: Dict[str, Any]) -> bytes:
290
469
  return head.encode() + body
291
470
 
292
471
 
472
+ def _stream(done: Dict[str, Any]) -> bytes:
473
+ """The answer as server-sent events; the connection closes after [DONE]."""
474
+ events = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks(done)) + "data: [DONE]\n\n"
475
+ head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"
476
+ return head.encode() + events.encode()
477
+
478
+
293
479
  def _error(status: int, message: str) -> bytes:
294
480
  return _response(status, {"error": {"message": message, "type": "forgeqe_copilot_gateway"}})
295
481
 
@@ -335,7 +521,9 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cop
335
521
  except ValueError:
336
522
  writer.write(_error(400, "The request body is not JSON."))
337
523
  return
338
- writer.write(_response(200, await copilot.complete(translate(body))))
524
+ req = translate(body)
525
+ done = await copilot.complete(req)
526
+ writer.write(_stream(done) if req["stream"] else _response(200, done))
339
527
  except GatewayError as e:
340
528
  writer.write(_error(e.status, e.message))
341
529
  except Exception as e: # The SDK's own errors: report them rather than drop the connection.
@@ -364,6 +552,9 @@ async def serve(out: TextIO, copilot: Any = None) -> int:
364
552
  except GatewayError as e:
365
553
  out.write(json.dumps({"error": e.message}) + "\n")
366
554
  return 1
555
+ except ImportError as e:
556
+ out.write(json.dumps({"error": f"GitHub's Copilot SDK is not installed in the ForgeQE engine ({e}): run forgeqe setup, which installs it from pypi.org."}) + "\n")
557
+ return 1
367
558
  except Exception as e:
368
559
  out.write(json.dumps({"error": f"GitHub Copilot could not start: {type(e).__name__}: {e}"[:1500]}) + "\n")
369
560
  return 1