@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.
- package/HOW_TO_USE.md +18 -10
- package/dist/forgeqe.js +913 -893
- package/docs/MODEL_ROUTING.md +9 -4
- package/docs/user-guide.html +24 -14
- package/forgeqe.example.yaml +2 -0
- package/package.json +1 -1
- package/prompts/README.md +1 -0
- package/prompts/site-lessons.md +12 -0
- package/py/forgeqe_engine/copilot_gateway.py +232 -41
|
@@ -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
|
|
5
|
-
the
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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":
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|