graphcore-studio 0.2.0__py3-none-win_amd64.whl
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.
- graphcore/__init__.py +303 -0
- graphcore/graphcore.dll +0 -0
- graphcore_studio-0.2.0.dist-info/METADATA +171 -0
- graphcore_studio-0.2.0.dist-info/RECORD +28 -0
- graphcore_studio-0.2.0.dist-info/WHEEL +5 -0
- graphcore_studio-0.2.0.dist-info/entry_points.txt +3 -0
- graphcore_studio-0.2.0.dist-info/licenses/LICENSE +21 -0
- include/graphcore/c_api.h +51 -0
- include/graphcore/graphcore.hpp +83 -0
- lib/cmake/GraphCore/GraphCoreConfig.cmake +26 -0
- lib/cmake/GraphCore/GraphCoreConfigVersion.cmake +65 -0
- lib/cmake/GraphCore/GraphCoreTargets-release.cmake +19 -0
- lib/cmake/GraphCore/GraphCoreTargets.cmake +107 -0
- lib/graphcore.lib +0 -0
- lib/graphcore_c.lib +0 -0
- studio/__init__.py +1 -0
- studio/cli.py +9 -0
- studio/engine.py +338 -0
- studio/example_plugin.py +8 -0
- studio/server.py +366 -0
- studio/templates/01-research.json +114 -0
- studio/templates/02-structured.json +86 -0
- studio/templates/03-tools.json +70 -0
- studio/templates/04-team.json +117 -0
- studio/templates/05-model.json +19 -0
- studio/web/app.js +108 -0
- studio/web/index.html +43 -0
- studio/web/style.css +9 -0
studio/server.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local-only Studio server. No third-party web framework or frontend build needed."""
|
|
3
|
+
import argparse
|
|
4
|
+
import copy
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import mimetypes
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import secrets
|
|
15
|
+
import shlex
|
|
16
|
+
import tempfile
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
sys.path.insert(0, str(ROOT / "python"))
|
|
25
|
+
sys.path.insert(0, str(ROOT))
|
|
26
|
+
from studio.engine import compile_workflow, validate, TOOLS, pydantic_available, provider_availability, register_tool
|
|
27
|
+
|
|
28
|
+
WEB = ROOT / "studio" / "web"
|
|
29
|
+
TEMPLATES = ROOT / "studio" / "templates"
|
|
30
|
+
ID = re.compile(r"^[a-zA-Z0-9_-]{1,80}$")
|
|
31
|
+
TERMINAL = {"completed", "failed", "cancelled", "suspended", "recoverable"}
|
|
32
|
+
ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
|
33
|
+
ENV_BLOCKLIST = {"PATH", "PYTHONPATH", "PYTHONHOME", "HOME", "SHELL", "VIRTUAL_ENV",
|
|
34
|
+
"LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH"}
|
|
35
|
+
|
|
36
|
+
def now(): return datetime.now(timezone.utc).isoformat()
|
|
37
|
+
|
|
38
|
+
def write_json(path, value):
|
|
39
|
+
temp = path.with_suffix(path.suffix + ".tmp")
|
|
40
|
+
temp.write_text(json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False), encoding="utf-8")
|
|
41
|
+
temp.replace(path)
|
|
42
|
+
|
|
43
|
+
def read_dotenv(path):
|
|
44
|
+
values = {}
|
|
45
|
+
try: lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
46
|
+
except FileNotFoundError: return values
|
|
47
|
+
for line in lines:
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if not line or line.startswith("#"): continue
|
|
50
|
+
if line.startswith("export "): line = line[7:].lstrip()
|
|
51
|
+
key, separator, value = line.partition("=")
|
|
52
|
+
key, value = key.strip(), value.strip()
|
|
53
|
+
if not separator or not ENV_NAME.fullmatch(key) or key in ENV_BLOCKLIST: continue
|
|
54
|
+
if value.startswith('"'):
|
|
55
|
+
try:
|
|
56
|
+
parsed, end = json.JSONDecoder().raw_decode(value)
|
|
57
|
+
if isinstance(parsed, str): value = parsed
|
|
58
|
+
except (ValueError, json.JSONDecodeError): pass
|
|
59
|
+
elif value.startswith("'") and value.endswith("'") and len(value) >= 2:
|
|
60
|
+
value = value[1:-1].replace("\\'", "'")
|
|
61
|
+
else:
|
|
62
|
+
value = re.split(r"\s+#", value, maxsplit=1)[0].rstrip()
|
|
63
|
+
values[key] = value
|
|
64
|
+
return values
|
|
65
|
+
|
|
66
|
+
def load_dotenv(path, override=False):
|
|
67
|
+
"""Load Studio's local .env file before model/provider construction."""
|
|
68
|
+
values = read_dotenv(path)
|
|
69
|
+
for key, value in values.items():
|
|
70
|
+
if override or key not in os.environ: os.environ[key] = value
|
|
71
|
+
return values
|
|
72
|
+
|
|
73
|
+
def write_dotenv(path, values):
|
|
74
|
+
path = Path(path)
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
body = "# GraphCore Studio local model integrations\n" + "".join(
|
|
77
|
+
key + "=" + json.dumps(value, ensure_ascii=False) + "\n" for key, value in sorted(values.items()))
|
|
78
|
+
fd, temporary = tempfile.mkstemp(prefix=".env-", dir=path.parent)
|
|
79
|
+
try:
|
|
80
|
+
if hasattr(os, "fchmod"): os.fchmod(fd, 0o600)
|
|
81
|
+
with os.fdopen(fd, "w", encoding="utf-8") as output: output.write(body)
|
|
82
|
+
os.replace(temporary, path)
|
|
83
|
+
try: os.chmod(path, 0o600)
|
|
84
|
+
except OSError: pass
|
|
85
|
+
finally:
|
|
86
|
+
try: os.unlink(temporary)
|
|
87
|
+
except FileNotFoundError: pass
|
|
88
|
+
|
|
89
|
+
class Store:
|
|
90
|
+
def __init__(self, root):
|
|
91
|
+
self.root = Path(root)
|
|
92
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
self.env_path = self.root / ".env"
|
|
94
|
+
self.base_environment = dict(os.environ)
|
|
95
|
+
self.env_lock = threading.RLock()
|
|
96
|
+
self.saved_environment = load_dotenv(self.env_path)
|
|
97
|
+
self.workflows = self.root / "workflows"; self.workflows.mkdir(exist_ok=True)
|
|
98
|
+
self.runs = self.root / "runs"; self.runs.mkdir(exist_ok=True)
|
|
99
|
+
self.lock = threading.RLock()
|
|
100
|
+
self.records = {}
|
|
101
|
+
self.active = {}
|
|
102
|
+
self.pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="graphcore")
|
|
103
|
+
self.slots = threading.BoundedSemaphore(4)
|
|
104
|
+
for path in sorted(self.runs.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:100]:
|
|
105
|
+
try:
|
|
106
|
+
record = json.loads(path.read_text())
|
|
107
|
+
if record["status"] in ("running", "queued", "cancelling"):
|
|
108
|
+
record["status"] = "recoverable" if self.checkpoint(record["id"]).exists() else "failed"
|
|
109
|
+
record["error"] = "Server stopped during execution. Recover from the last committed checkpoint."
|
|
110
|
+
if self.checkpoint(record["id"]).exists():
|
|
111
|
+
try:
|
|
112
|
+
from graphcore import Graph
|
|
113
|
+
with Graph() as reader:
|
|
114
|
+
saved = reader.inspect_checkpoint(self.checkpoint(record["id"]))
|
|
115
|
+
record["state"] = saved.state
|
|
116
|
+
if saved.suspended:
|
|
117
|
+
record.update(status="suspended", prompt=saved.prompt, error="")
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
self.records[record["id"]] = record
|
|
121
|
+
except (ValueError, KeyError, OSError): pass
|
|
122
|
+
|
|
123
|
+
def environment_status(self):
|
|
124
|
+
stored = set(self.saved_environment)
|
|
125
|
+
names = sorted(stored | {name for provider in provider_availability() for name in provider.get("env", [])})
|
|
126
|
+
return [{"name": name, "configured": name in os.environ, "stored": name in stored}
|
|
127
|
+
for name in names]
|
|
128
|
+
|
|
129
|
+
def update_environment(self, values, remove):
|
|
130
|
+
if not isinstance(values, dict) or not isinstance(remove, list):
|
|
131
|
+
raise ValueError("Environment values must be an object and removals a list")
|
|
132
|
+
if len(values) + len(remove) > 100: raise ValueError("Configure at most 100 environment variables")
|
|
133
|
+
cleaned = {}
|
|
134
|
+
for key, value in values.items():
|
|
135
|
+
if not isinstance(key, str) or not ENV_NAME.fullmatch(key) or key in ENV_BLOCKLIST:
|
|
136
|
+
raise ValueError("Invalid or reserved environment variable name")
|
|
137
|
+
if not isinstance(value, str) or len(value) > 10000 or "\x00" in value or "\n" in value or "\r" in value:
|
|
138
|
+
raise ValueError("Environment values must be single-line text up to 10000 characters")
|
|
139
|
+
cleaned[key] = value
|
|
140
|
+
if any(not isinstance(key, str) or not ENV_NAME.fullmatch(key) or key in ENV_BLOCKLIST for key in remove):
|
|
141
|
+
raise ValueError("Invalid or reserved environment variable name to remove")
|
|
142
|
+
with self.env_lock:
|
|
143
|
+
result = dict(self.saved_environment)
|
|
144
|
+
for key in remove:
|
|
145
|
+
result.pop(key, None)
|
|
146
|
+
if key in self.base_environment: os.environ[key] = self.base_environment[key]
|
|
147
|
+
else: os.environ.pop(key, None)
|
|
148
|
+
result.update(cleaned)
|
|
149
|
+
write_dotenv(self.env_path, result)
|
|
150
|
+
self.saved_environment = result
|
|
151
|
+
os.environ.update(cleaned)
|
|
152
|
+
return self.environment_status()
|
|
153
|
+
|
|
154
|
+
def checkpoint(self, ident): return self.runs / (ident + ".checkpoint")
|
|
155
|
+
|
|
156
|
+
def snapshot(self, ident):
|
|
157
|
+
with self.lock:
|
|
158
|
+
if ident not in self.records: raise ValueError("Run does not exist")
|
|
159
|
+
return copy.deepcopy(self.records[ident])
|
|
160
|
+
|
|
161
|
+
def history(self):
|
|
162
|
+
with self.lock:
|
|
163
|
+
return [{k: copy.deepcopy(r.get(k)) for k in ("id", "name", "status", "created", "duration_ms", "error")}
|
|
164
|
+
for r in sorted(self.records.values(), key=lambda r: r["created"], reverse=True)[:100]]
|
|
165
|
+
|
|
166
|
+
def save_record(self, ident):
|
|
167
|
+
write_json(self.runs / (ident + ".json"), self.records[ident])
|
|
168
|
+
|
|
169
|
+
def start(self, workflow, inputs, max_steps=100):
|
|
170
|
+
errors = validate(workflow)
|
|
171
|
+
if errors: raise ValueError("\n".join(errors))
|
|
172
|
+
if not isinstance(inputs, dict): raise ValueError("Inputs must be a JSON object")
|
|
173
|
+
if not isinstance(max_steps, int) or not 1 <= max_steps <= 1000: raise ValueError("Step limit must be 1–1000")
|
|
174
|
+
if not self.slots.acquire(blocking=False): raise ValueError("Four runs are already active; wait for one to finish")
|
|
175
|
+
ident = uuid.uuid4().hex
|
|
176
|
+
record = {"id": ident, "name": workflow.get("name", "Untitled workflow"), "workflow": copy.deepcopy(workflow),
|
|
177
|
+
"inputs": copy.deepcopy(inputs), "max_steps": max_steps, "created": now(), "status": "queued",
|
|
178
|
+
"events": [], "state": {}, "prompt": "", "error": "", "duration_ms": 0}
|
|
179
|
+
try:
|
|
180
|
+
with self.lock:
|
|
181
|
+
self.records[ident] = record
|
|
182
|
+
self.save_record(ident)
|
|
183
|
+
self.pool.submit(self.execute, ident, "invoke", None)
|
|
184
|
+
except BaseException:
|
|
185
|
+
self.slots.release()
|
|
186
|
+
raise
|
|
187
|
+
return ident
|
|
188
|
+
|
|
189
|
+
def continue_run(self, ident, action, response=None):
|
|
190
|
+
with self.lock:
|
|
191
|
+
record = self.records.get(ident)
|
|
192
|
+
if not record: raise ValueError("Run does not exist")
|
|
193
|
+
if action == "resume" and record["status"] != "suspended": raise ValueError("Run is not awaiting approval")
|
|
194
|
+
if action == "recover" and record["status"] not in ("recoverable", "failed", "cancelled"):
|
|
195
|
+
raise ValueError("Run is not recoverable")
|
|
196
|
+
if not self.checkpoint(ident).exists(): raise ValueError("No checkpoint exists for this run")
|
|
197
|
+
if not self.slots.acquire(blocking=False): raise ValueError("Four runs are already active")
|
|
198
|
+
record["status"] = "queued"; record["error"] = ""
|
|
199
|
+
self.save_record(ident)
|
|
200
|
+
self.pool.submit(self.execute, ident, action, response)
|
|
201
|
+
|
|
202
|
+
def cancel(self, ident):
|
|
203
|
+
with self.lock:
|
|
204
|
+
record = self.records.get(ident)
|
|
205
|
+
if not record or record["status"] not in ("queued", "running", "cancelling"):
|
|
206
|
+
raise ValueError("Run is not active")
|
|
207
|
+
record["status"] = "cancelling"
|
|
208
|
+
if ident in self.active: self.active[ident].cancel()
|
|
209
|
+
self.save_record(ident)
|
|
210
|
+
|
|
211
|
+
def execute(self, ident, action, response):
|
|
212
|
+
start = time.monotonic()
|
|
213
|
+
graph = None
|
|
214
|
+
def emit(kind, **data):
|
|
215
|
+
with self.lock:
|
|
216
|
+
record = self.records[ident]
|
|
217
|
+
record["events"].append({"sequence": len(record["events"]) + 1, "type": kind, "time": now(), **data})
|
|
218
|
+
# Metadata follows a committed native checkpoint, never leads it.
|
|
219
|
+
if kind in ("checkpoint.committed", "run.interrupted"):
|
|
220
|
+
self.save_record(ident)
|
|
221
|
+
try:
|
|
222
|
+
with self.lock:
|
|
223
|
+
record = self.records[ident]
|
|
224
|
+
workflow, inputs, budget = record["workflow"], record["inputs"], record["max_steps"]
|
|
225
|
+
graph = compile_workflow(workflow, emit)
|
|
226
|
+
with self.lock:
|
|
227
|
+
self.active[ident] = graph
|
|
228
|
+
if record["status"] == "cancelling": graph.cancel()
|
|
229
|
+
else: record["status"] = "running"
|
|
230
|
+
if action == "resume": result = graph.resume(str(self.checkpoint(ident)), response=response, max_steps=budget)
|
|
231
|
+
elif action == "recover": result = graph.recover(str(self.checkpoint(ident)), max_steps=budget)
|
|
232
|
+
else: result = graph.invoke(inputs, checkpoint=str(self.checkpoint(ident)), max_steps=budget)
|
|
233
|
+
with self.lock:
|
|
234
|
+
record.update(status="suspended" if result.suspended else "completed", state=result.state,
|
|
235
|
+
prompt=result.prompt, steps=result.steps)
|
|
236
|
+
except Exception as error:
|
|
237
|
+
with self.lock:
|
|
238
|
+
record = self.records[ident]
|
|
239
|
+
record["status"] = "cancelled" if record["status"] == "cancelling" else "failed"
|
|
240
|
+
record["error"] = str(error)
|
|
241
|
+
finally:
|
|
242
|
+
with self.lock:
|
|
243
|
+
self.active.pop(ident, None)
|
|
244
|
+
record["duration_ms"] += round((time.monotonic() - start) * 1000)
|
|
245
|
+
self.save_record(ident)
|
|
246
|
+
if graph: graph.close()
|
|
247
|
+
self.slots.release()
|
|
248
|
+
|
|
249
|
+
def close(self):
|
|
250
|
+
with self.lock:
|
|
251
|
+
for graph in self.active.values(): graph.cancel()
|
|
252
|
+
self.pool.shutdown(wait=True)
|
|
253
|
+
|
|
254
|
+
class Handler(BaseHTTPRequestHandler):
|
|
255
|
+
server_version = "GraphCoreStudio/0.2"
|
|
256
|
+
def log_message(self, format, *args): pass
|
|
257
|
+
|
|
258
|
+
def allowed_host(self):
|
|
259
|
+
return self.headers.get("Host") in ("127.0.0.1:" + str(self.server.server_port), "localhost:" + str(self.server.server_port))
|
|
260
|
+
|
|
261
|
+
def send(self, data, code=200, content_type="application/json; charset=utf-8"):
|
|
262
|
+
body = json.dumps(data, ensure_ascii=False, allow_nan=False).encode() if not isinstance(data, bytes) else data
|
|
263
|
+
self.send_response(code)
|
|
264
|
+
self.send_header("Content-Type", content_type)
|
|
265
|
+
self.send_header("Content-Length", str(len(body)))
|
|
266
|
+
self.send_header("Cache-Control", "no-store")
|
|
267
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
268
|
+
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
|
|
269
|
+
self.end_headers()
|
|
270
|
+
try: self.wfile.write(body)
|
|
271
|
+
except (BrokenPipeError, ConnectionResetError): pass
|
|
272
|
+
|
|
273
|
+
def do_GET(self):
|
|
274
|
+
if not self.allowed_host(): return self.send({"error": "Invalid local Host"}, 403)
|
|
275
|
+
path = urllib.parse.urlsplit(self.path).path
|
|
276
|
+
try:
|
|
277
|
+
if path == "/api/bootstrap":
|
|
278
|
+
try:
|
|
279
|
+
from graphcore import Graph
|
|
280
|
+
with Graph(): pass
|
|
281
|
+
native, error = True, ""
|
|
282
|
+
except Exception as failure: native, error = False, str(failure)
|
|
283
|
+
templates = [json.loads(p.read_text()) for p in sorted(TEMPLATES.glob("*.json"))]
|
|
284
|
+
return self.send({"csrf": self.server.token, "native": native, "native_error": error,
|
|
285
|
+
"pydantic": pydantic_available(), "providers": provider_availability(), "templates": templates,
|
|
286
|
+
"tools": [{"name": k, "description": v["description"]} for k,v in TOOLS.items()]})
|
|
287
|
+
if path == "/api/settings/environment":
|
|
288
|
+
return self.send({"variables": self.server.store.environment_status()})
|
|
289
|
+
if path == "/api/workflows":
|
|
290
|
+
with self.server.store.lock:
|
|
291
|
+
items = [json.loads(p.read_text()) for p in self.server.store.workflows.glob("*.json")]
|
|
292
|
+
return self.send(items)
|
|
293
|
+
if path == "/api/runs": return self.send(self.server.store.history())
|
|
294
|
+
if path.startswith("/api/runs/"):
|
|
295
|
+
ident = path.rsplit("/", 1)[1]
|
|
296
|
+
if not ID.fullmatch(ident): raise ValueError("Invalid run ID")
|
|
297
|
+
return self.send(self.server.store.snapshot(ident))
|
|
298
|
+
files = {"/": "index.html", "/app.js": "app.js", "/style.css": "style.css"}
|
|
299
|
+
if path not in files: return self.send({"error": "Not found"}, 404)
|
|
300
|
+
file = WEB / files[path]
|
|
301
|
+
return self.send(file.read_bytes(), content_type=mimetypes.guess_type(file.name)[0] + "; charset=utf-8")
|
|
302
|
+
except (ValueError, OSError) as error: self.send({"error": str(error)}, 400)
|
|
303
|
+
|
|
304
|
+
def do_POST(self):
|
|
305
|
+
if not self.allowed_host(): return self.send({"error": "Invalid local Host"}, 403)
|
|
306
|
+
origin = self.headers.get("Origin")
|
|
307
|
+
allowed = ("http://127.0.0.1:" + str(self.server.server_port), "http://localhost:" + str(self.server.server_port))
|
|
308
|
+
if (origin is not None and origin not in allowed) or not secrets.compare_digest(self.headers.get("X-Studio-Token", ""), self.server.token):
|
|
309
|
+
return self.send({"error": "Request must originate from the local Studio"}, 403)
|
|
310
|
+
try:
|
|
311
|
+
size = int(self.headers.get("Content-Length", "0"))
|
|
312
|
+
if size <= 0 or size > 2 * 1024 * 1024: raise ValueError("Request body must be 1 byte–2 MiB")
|
|
313
|
+
body = json.loads(self.rfile.read(size), parse_constant=lambda value: (_ for _ in ()).throw(ValueError("Non-finite JSON number")))
|
|
314
|
+
if not isinstance(body, dict): raise ValueError("Request must be a JSON object")
|
|
315
|
+
path = urllib.parse.urlsplit(self.path).path
|
|
316
|
+
if path == "/api/validate": return self.send({"errors": validate(body.get("workflow"))})
|
|
317
|
+
if path == "/api/settings/environment":
|
|
318
|
+
return self.send({"variables": self.server.store.update_environment(
|
|
319
|
+
body.get("values", {}), body.get("remove", []))})
|
|
320
|
+
if path == "/api/workflows":
|
|
321
|
+
workflow = body.get("workflow")
|
|
322
|
+
if not isinstance(workflow, dict) or workflow.get("format") != "graphcore.studio.v1": raise ValueError("Invalid workflow document")
|
|
323
|
+
# Drafts may have incomplete connections; validate before running.
|
|
324
|
+
ident = workflow.get("id")
|
|
325
|
+
if not isinstance(ident, str) or not ID.fullmatch(ident): raise ValueError("Invalid workflow ID")
|
|
326
|
+
with self.server.store.lock: write_json(self.server.store.workflows / (ident + ".json"), workflow)
|
|
327
|
+
return self.send({"saved": True, "id": ident})
|
|
328
|
+
if path == "/api/runs":
|
|
329
|
+
ident = self.server.store.start(body.get("workflow"), body.get("inputs", {}), body.get("max_steps", 100))
|
|
330
|
+
return self.send({"id": ident}, 202)
|
|
331
|
+
match = re.fullmatch(r"/api/runs/([a-zA-Z0-9_-]+)/(?P<action>resume|recover|cancel)", path)
|
|
332
|
+
if match:
|
|
333
|
+
ident, action = match.group(1), match.group("action")
|
|
334
|
+
if action == "cancel": self.server.store.cancel(ident)
|
|
335
|
+
else: self.server.store.continue_run(ident, action, body.get("response"))
|
|
336
|
+
return self.send({"id": ident}, 202)
|
|
337
|
+
return self.send({"error": "Not found"}, 404)
|
|
338
|
+
except (ValueError, OSError, TypeError, KeyError) as error:
|
|
339
|
+
self.send({"error": str(error)}, 400)
|
|
340
|
+
|
|
341
|
+
class Server(ThreadingHTTPServer):
|
|
342
|
+
daemon_threads = True
|
|
343
|
+
def __init__(self, address, store):
|
|
344
|
+
super().__init__(address, Handler)
|
|
345
|
+
self.store, self.token = store, secrets.token_urlsafe(32)
|
|
346
|
+
|
|
347
|
+
def main():
|
|
348
|
+
parser = argparse.ArgumentParser(description="GraphCore Studio — local visual agent builder")
|
|
349
|
+
parser.add_argument("--port", type=int, default=8787)
|
|
350
|
+
parser.add_argument("--data", default=os.environ.get("GRAPHCORE_STUDIO_DATA", str(ROOT / "studio" / "data")))
|
|
351
|
+
parser.add_argument("--plugin", action="append", default=[], help="Trusted local Python module file registering tools")
|
|
352
|
+
args = parser.parse_args()
|
|
353
|
+
load_dotenv(Path(args.data) / ".env")
|
|
354
|
+
for i, filename in enumerate(args.plugin):
|
|
355
|
+
spec = importlib.util.spec_from_file_location("studio_plugin_" + str(i), filename)
|
|
356
|
+
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
|
357
|
+
module.register(register_tool)
|
|
358
|
+
store = Store(args.data)
|
|
359
|
+
server = Server(("127.0.0.1", args.port), store)
|
|
360
|
+
print("GraphCore Studio: http://127.0.0.1:" + str(server.server_port), flush=True)
|
|
361
|
+
print("Local files: " + str(store.root), flush=True)
|
|
362
|
+
try: server.serve_forever()
|
|
363
|
+
except KeyboardInterrupt: pass
|
|
364
|
+
finally: server.server_close(); store.close()
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__": main()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "graphcore.studio.v1",
|
|
3
|
+
"id": "research-studio",
|
|
4
|
+
"name": "Research & review",
|
|
5
|
+
"description": "A two-agent writing team, with a native condition and a human checkpoint.",
|
|
6
|
+
"inputs": {
|
|
7
|
+
"input": "How can small teams use local AI responsibly?",
|
|
8
|
+
"needs_review": true
|
|
9
|
+
},
|
|
10
|
+
"nodes": [
|
|
11
|
+
{
|
|
12
|
+
"id": "input",
|
|
13
|
+
"type": "input",
|
|
14
|
+
"label": "Start with a topic",
|
|
15
|
+
"x": 60,
|
|
16
|
+
"y": 180,
|
|
17
|
+
"config": {}
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "researcher",
|
|
21
|
+
"type": "agent",
|
|
22
|
+
"label": "Researcher",
|
|
23
|
+
"x": 350,
|
|
24
|
+
"y": 60,
|
|
25
|
+
"config": {
|
|
26
|
+
"provider": "demo",
|
|
27
|
+
"model": "llama3.2",
|
|
28
|
+
"system": "You are a researcher. Find clear, useful ideas.",
|
|
29
|
+
"prompt": "Research this topic: {{input}}",
|
|
30
|
+
"demo_response": "Research notes: {{input}}\n\n1. Start with a narrow, measurable task.\n2. Keep sensitive data local.\n3. Review important decisions with a person.",
|
|
31
|
+
"output_key": "research"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "writer",
|
|
36
|
+
"type": "agent",
|
|
37
|
+
"label": "Writer",
|
|
38
|
+
"x": 650,
|
|
39
|
+
"y": 60,
|
|
40
|
+
"config": {
|
|
41
|
+
"provider": "demo",
|
|
42
|
+
"model": "llama3.2",
|
|
43
|
+
"system": "You are an editor. Turn research into a concise, practical brief.",
|
|
44
|
+
"prompt": "Write a brief using these notes:\n{{research}}",
|
|
45
|
+
"demo_response": "A practical guide\n\n{{research}}\n\nNext step: build a small pilot, evaluate its output, and expand only after review.",
|
|
46
|
+
"output_key": "draft"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "review_gate",
|
|
51
|
+
"type": "condition",
|
|
52
|
+
"label": "Needs human review?",
|
|
53
|
+
"x": 950,
|
|
54
|
+
"y": 180,
|
|
55
|
+
"config": {
|
|
56
|
+
"field": "needs_review",
|
|
57
|
+
"expected": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "approval",
|
|
62
|
+
"type": "approval",
|
|
63
|
+
"label": "Human checkpoint",
|
|
64
|
+
"x": 650,
|
|
65
|
+
"y": 350,
|
|
66
|
+
"config": {
|
|
67
|
+
"prompt": "Review this draft before continuing:\n\n{{draft}}",
|
|
68
|
+
"output_key": "review_response"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"id": "output",
|
|
73
|
+
"type": "output",
|
|
74
|
+
"label": "Final brief",
|
|
75
|
+
"x": 350,
|
|
76
|
+
"y": 350,
|
|
77
|
+
"config": {
|
|
78
|
+
"template": "{{draft}}"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"edges": [
|
|
83
|
+
{
|
|
84
|
+
"source": "input",
|
|
85
|
+
"target": "researcher",
|
|
86
|
+
"port": "next"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"source": "researcher",
|
|
90
|
+
"target": "writer",
|
|
91
|
+
"port": "next"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"source": "writer",
|
|
95
|
+
"target": "review_gate",
|
|
96
|
+
"port": "next"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"source": "review_gate",
|
|
100
|
+
"target": "approval",
|
|
101
|
+
"port": "true"
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"source": "review_gate",
|
|
105
|
+
"target": "output",
|
|
106
|
+
"port": "false"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"source": "approval",
|
|
110
|
+
"target": "output",
|
|
111
|
+
"port": "next"
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "graphcore.studio.v1",
|
|
3
|
+
"id": "structured-output",
|
|
4
|
+
"name": "Structured extraction",
|
|
5
|
+
"description": "Generate a JSON object, then validate its fields before producing output.",
|
|
6
|
+
"inputs": {
|
|
7
|
+
"input": "A visual workflow builder for local agents."
|
|
8
|
+
},
|
|
9
|
+
"nodes": [
|
|
10
|
+
{
|
|
11
|
+
"id": "input",
|
|
12
|
+
"type": "input",
|
|
13
|
+
"label": "Your source text",
|
|
14
|
+
"x": 50,
|
|
15
|
+
"y": 150,
|
|
16
|
+
"config": {}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "extract",
|
|
20
|
+
"type": "agent",
|
|
21
|
+
"label": "Extract a record",
|
|
22
|
+
"x": 350,
|
|
23
|
+
"y": 150,
|
|
24
|
+
"config": {
|
|
25
|
+
"provider": "demo",
|
|
26
|
+
"model": "llama3.2",
|
|
27
|
+
"system": "Extract a JSON object with summary (string) and confidence (number). Return JSON only.",
|
|
28
|
+
"prompt": "Extract from: {{input}}",
|
|
29
|
+
"demo_response": "{\"summary\":\"A local visual workflow builder\",\"confidence\":0.95}",
|
|
30
|
+
"json_output": true,
|
|
31
|
+
"output_key": "record"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "validate",
|
|
36
|
+
"type": "schema",
|
|
37
|
+
"label": "Validate structure",
|
|
38
|
+
"x": 650,
|
|
39
|
+
"y": 150,
|
|
40
|
+
"config": {
|
|
41
|
+
"input_field": "record",
|
|
42
|
+
"output_key": "validated",
|
|
43
|
+
"engine": "builtin",
|
|
44
|
+
"fields": [
|
|
45
|
+
{
|
|
46
|
+
"name": "summary",
|
|
47
|
+
"type": "string",
|
|
48
|
+
"required": true
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "confidence",
|
|
52
|
+
"type": "number",
|
|
53
|
+
"required": true
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "output",
|
|
60
|
+
"type": "output",
|
|
61
|
+
"label": "Validated result",
|
|
62
|
+
"x": 950,
|
|
63
|
+
"y": 150,
|
|
64
|
+
"config": {
|
|
65
|
+
"template": "{{validated}}"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"edges": [
|
|
70
|
+
{
|
|
71
|
+
"source": "input",
|
|
72
|
+
"target": "extract",
|
|
73
|
+
"port": "next"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"source": "extract",
|
|
77
|
+
"target": "validate",
|
|
78
|
+
"port": "next"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"source": "validate",
|
|
82
|
+
"target": "output",
|
|
83
|
+
"port": "next"
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "graphcore.studio.v1",
|
|
3
|
+
"id": "python-tools",
|
|
4
|
+
"name": "Python tool chain",
|
|
5
|
+
"description": "Call registered Python functions and route their output through the native engine.",
|
|
6
|
+
"inputs": {
|
|
7
|
+
"input": "Local workflows make useful tools accessible to everyone."
|
|
8
|
+
},
|
|
9
|
+
"nodes": [
|
|
10
|
+
{
|
|
11
|
+
"id": "input",
|
|
12
|
+
"type": "input",
|
|
13
|
+
"label": "Start with text",
|
|
14
|
+
"x": 50,
|
|
15
|
+
"y": 150,
|
|
16
|
+
"config": {}
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "keywords",
|
|
20
|
+
"type": "tool",
|
|
21
|
+
"label": "Extract keywords",
|
|
22
|
+
"x": 350,
|
|
23
|
+
"y": 150,
|
|
24
|
+
"config": {
|
|
25
|
+
"tool": "keywords",
|
|
26
|
+
"input_field": "input",
|
|
27
|
+
"output_key": "keywords"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "count",
|
|
32
|
+
"type": "tool",
|
|
33
|
+
"label": "Measure the text",
|
|
34
|
+
"x": 650,
|
|
35
|
+
"y": 150,
|
|
36
|
+
"config": {
|
|
37
|
+
"tool": "word_count",
|
|
38
|
+
"input_field": "input",
|
|
39
|
+
"output_key": "stats"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "output",
|
|
44
|
+
"type": "output",
|
|
45
|
+
"label": "Results",
|
|
46
|
+
"x": 950,
|
|
47
|
+
"y": 150,
|
|
48
|
+
"config": {
|
|
49
|
+
"template": "Keywords: {{keywords}}\n\nText statistics: {{stats}}"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"edges": [
|
|
54
|
+
{
|
|
55
|
+
"source": "input",
|
|
56
|
+
"target": "keywords",
|
|
57
|
+
"port": "next"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"source": "keywords",
|
|
61
|
+
"target": "count",
|
|
62
|
+
"port": "next"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"source": "count",
|
|
66
|
+
"target": "output",
|
|
67
|
+
"port": "next"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|