gini-toolkit 6.0.1.dev0__py3-none-any.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.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,43 @@
1
+ """LLM backend abstraction.
2
+
3
+ One thin contract so the model is a config choice: Ollama (local/campus) by default,
4
+ any OpenAI-compatible server, or cloud. `chat()` yields a stream of Chunks (text deltas
5
+ and/or tool calls). The agent loop drives it; the deterministic core means the app
6
+ still works if no backend is reachable.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass, field
12
+ from typing import Protocol, runtime_checkable
13
+
14
+
15
+ @dataclass
16
+ class Message:
17
+ role: str # system | user | assistant | tool
18
+ content: str = ""
19
+ name: str | None = None # tool name (role == "tool")
20
+ tool_calls: list["ToolCall"] = field(default_factory=list)
21
+
22
+
23
+ @dataclass
24
+ class ToolCall:
25
+ name: str
26
+ arguments: dict
27
+ id: str = ""
28
+
29
+
30
+ @dataclass
31
+ class Chunk:
32
+ text: str = ""
33
+ tool_call: ToolCall | None = None
34
+
35
+
36
+ @runtime_checkable
37
+ class LLMBackend(Protocol):
38
+ def chat(self, messages: list[Message], tools: list[dict] | None = None,
39
+ stream: bool = False) -> Iterator[Chunk]:
40
+ ...
41
+
42
+ def available(self) -> bool:
43
+ ...
gini/agent/llm/fake.py ADDED
@@ -0,0 +1,25 @@
1
+ """Scripted backend for tests and offline demos.
2
+
3
+ Returns a pre-set list of Chunks per chat() call, so the agent loop can be exercised
4
+ deterministically without a model. Each entry in `script` is the list of Chunks the
5
+ n-th chat() call yields (e.g. a tool_call turn, then a text turn).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+
11
+ from .backend import Chunk
12
+
13
+
14
+ class ScriptedBackend:
15
+ def __init__(self, script: list[list[Chunk]]) -> None:
16
+ self.script = list(script)
17
+ self.calls: list[list] = [] # captured (messages, tools) per call
18
+
19
+ def available(self) -> bool:
20
+ return True
21
+
22
+ def chat(self, messages, tools=None, stream=False) -> Iterator[Chunk]:
23
+ self.calls.append((list(messages), tools))
24
+ chunks = self.script.pop(0) if self.script else [Chunk(text="(done)")]
25
+ yield from chunks
@@ -0,0 +1,206 @@
1
+ """Ollama backend (self-hosted, local/campus).
2
+
3
+ Talks to Ollama's /api/chat with OpenAI-style tools. Uses only the stdlib (urllib) so
4
+ there's no extra dependency. The HTTP call is injectable (`transport=`) so the request
5
+ shaping and response parsing are unit-testable without a running server.
6
+
7
+ Config: url (e.g. http://gpu.cs.univ.edu:11434), model, timeout.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import urllib.error
14
+ import urllib.request
15
+ from collections.abc import Callable, Iterator
16
+
17
+ from .backend import Chunk, Message, ToolCall
18
+
19
+ # reasoning ("thinking") models wrap their chain-of-thought in tags — strip it so it
20
+ # never reaches the chat or the JSON tool-parser. Covers <think>…</think> and Gemma's
21
+ # <|think|>…<|/think|> styles, terminated or not.
22
+ _THINK_BLOCK = re.compile(r"<\|?\s*think\s*\|?>.*?<\|?\s*/\s*think\s*\|?>",
23
+ re.DOTALL | re.IGNORECASE)
24
+ _THINK_TAG = re.compile(r"<\|?\s*/?\s*think[^>]*?\|?>", re.IGNORECASE)
25
+
26
+
27
+ def strip_thinking(text: str) -> str:
28
+ """Remove a model's reasoning, returning just the answer it intends to show."""
29
+ if not text:
30
+ return text
31
+ for close in ("</think>", "<|/think|>", "<|end_think|>"):
32
+ if close in text: # answer follows the last closing tag
33
+ text = text.rsplit(close, 1)[1]
34
+ text = _THINK_BLOCK.sub("", text)
35
+ text = _THINK_TAG.sub("", text) # drop any stray/open think markers
36
+ return text.strip()
37
+
38
+
39
+ def _to_ollama_message(m: Message) -> dict:
40
+ msg: dict = {"role": m.role, "content": m.content}
41
+ if m.role == "tool" and m.name:
42
+ msg["tool_name"] = m.name
43
+ if m.tool_calls:
44
+ msg["tool_calls"] = [
45
+ {"function": {"name": tc.name, "arguments": tc.arguments}} for tc in m.tool_calls
46
+ ]
47
+ return msg
48
+
49
+
50
+ class OllamaBackend:
51
+ def __init__(self, url: str = "http://localhost:11434", model: str = "llama3.1",
52
+ timeout: float = 120.0, think: bool = False, stream: bool = True,
53
+ num_ctx: int = 8192, embed_model: str = "all-minilm",
54
+ transport: Callable[[str, dict], dict] | None = None) -> None:
55
+ self.url = url.rstrip("/")
56
+ self.model = model
57
+ self.embed_model = embed_model # small embed model for L2 semantic recall (/api/embed)
58
+ self.timeout = timeout
59
+ # Ollama's DEFAULT context window is only 2048 tokens — far smaller than the model's
60
+ # real window — and it SILENTLY TRUNCATES the prompt when exceeded. Set it explicitly
61
+ # so the catalog/canvas/question always fit. (Ollama clamps to the model's real max.)
62
+ self.num_ctx = num_ctx
63
+ self.think = think # ask reasoning models to think (Ollama `think` flag)
64
+ self.stream = stream # stream tokens for a live "typing" feel
65
+ self._injected = transport is not None # tests inject a transport (non-streaming)
66
+ self._transport = transport or self._http_post
67
+
68
+ # -- HTTP ---------------------------------------------------------------- #
69
+ def _http_post(self, path: str, payload: dict) -> dict:
70
+ req = urllib.request.Request(
71
+ self.url + path,
72
+ data=json.dumps(payload).encode(),
73
+ headers={"Content-Type": "application/json"},
74
+ method="POST",
75
+ )
76
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
77
+ return json.loads(resp.read().decode())
78
+
79
+ def available(self) -> bool:
80
+ try:
81
+ req = urllib.request.Request(self.url + "/api/tags", method="GET")
82
+ urllib.request.urlopen(req, timeout=3.0)
83
+ return True
84
+ except (urllib.error.URLError, OSError):
85
+ return False
86
+
87
+ # -- embeddings (L2 semantic recall) ------------------------------------- #
88
+ def embed(self, texts: list[str]) -> list[list[float]]:
89
+ """Embed one or more texts via Ollama's `/api/embed` (same server as chat). Returns a
90
+ list of vectors aligned with `texts`; [] on any failure so callers degrade to lexical."""
91
+ if not texts:
92
+ return []
93
+ try:
94
+ data = self._transport("/api/embed", {"model": self.embed_model, "input": texts})
95
+ except Exception:
96
+ return []
97
+ return data.get("embeddings") or []
98
+
99
+ # -- chat ---------------------------------------------------------------- #
100
+ def chat(self, messages: list[Message], tools: list[dict] | None = None,
101
+ stream: bool = False, schema: dict | None = None) -> Iterator[Chunk]:
102
+ """`schema` (optional) is a JSON Schema passed as Ollama's `format` field — the server
103
+ CONSTRAINS decoding so the reply is guaranteed to match (structured outputs). Used by the
104
+ structured personas (coverage/classifier/critic JSON) so malformed output becomes a rare
105
+ transport error instead of a routine small-model failure. Reasoning-2.0 §10 amendment."""
106
+ base = {
107
+ "model": self.model,
108
+ "messages": [_to_ollama_message(m) for m in messages],
109
+ "stream": False, # tool turns are most reliable non-streamed
110
+ "options": {"num_ctx": self.num_ctx}, # don't let Ollama truncate to 2048
111
+ }
112
+ # Try the richest request first, then degrade: a small model may not support
113
+ # tools and/or thinking, and Ollama errors rather than ignoring them. Dropping
114
+ # tools is safe — the agent loop's JSON-action fallback still drives tools.
115
+ attempts: list[dict] = []
116
+ full = dict(base)
117
+ if self.think:
118
+ full["think"] = True
119
+ if tools:
120
+ full["tools"] = tools
121
+ attempts.append(full)
122
+ if tools: # without tools (keep think)
123
+ p = dict(base)
124
+ if self.think:
125
+ p["think"] = True
126
+ attempts.append(p)
127
+ attempts.append(dict(base)) # bare minimum
128
+ if schema is not None:
129
+ # constrain every attempt; keep ONE unconstrained fallback (an older server may
130
+ # reject `format` — the callers' tolerant first_json parsing still applies then).
131
+ attempts = [{**p, "format": schema} for p in attempts] + [dict(base)]
132
+
133
+ # Stream the answer token-by-token over real HTTP (live "typing" feel). Tool
134
+ # turns still come back whole in the final streamed object. Injected transports
135
+ # (tests) keep the simple non-streaming path. Structured (schema) calls are
136
+ # machine-consumed, never displayed — no reason to stream them.
137
+ if self.stream and not self._injected and schema is None:
138
+ last_err = None
139
+ for payload in attempts:
140
+ try:
141
+ yield from self._chat_stream(payload)
142
+ return
143
+ except Exception as e: # noqa: BLE001 — degrade on reject
144
+ last_err = e
145
+ raise last_err
146
+
147
+ data, last_err = None, None
148
+ for payload in attempts:
149
+ try:
150
+ data = self._transport("/api/chat", payload)
151
+ break
152
+ except Exception as e: # noqa: BLE001 — degrade on any server reject
153
+ last_err = e
154
+ if data is None:
155
+ raise last_err
156
+ yield from self._parse(data)
157
+
158
+ def _chat_stream(self, payload: dict) -> Iterator[Chunk]:
159
+ """Stream Ollama's NDJSON response — one JSON object per line, each carrying a
160
+ small content delta — yielding a Chunk per delta and tool calls at the end."""
161
+ body = json.dumps({**payload, "stream": True}).encode()
162
+ req = urllib.request.Request(self.url + "/api/chat", data=body,
163
+ headers={"Content-Type": "application/json"},
164
+ method="POST")
165
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
166
+ for raw in resp: # iterate lines as they arrive
167
+ line = raw.decode(errors="replace").strip()
168
+ if not line:
169
+ continue
170
+ try:
171
+ obj = json.loads(line)
172
+ except json.JSONDecodeError:
173
+ continue
174
+ msg = obj.get("message", {}) or {}
175
+ delta = msg.get("content") or ""
176
+ if delta:
177
+ yield Chunk(text=delta) # not stripped: deltas are tiny
178
+ for call in msg.get("tool_calls", []) or []:
179
+ fn = call.get("function", {})
180
+ args = fn.get("arguments", {})
181
+ if isinstance(args, str):
182
+ try:
183
+ args = json.loads(args)
184
+ except json.JSONDecodeError:
185
+ args = {}
186
+ yield Chunk(tool_call=ToolCall(name=fn.get("name", ""), arguments=args or {}))
187
+ if obj.get("done"):
188
+ break
189
+
190
+ @staticmethod
191
+ def _parse(data: dict) -> Iterator[Chunk]:
192
+ msg = data.get("message", {}) or {}
193
+ # Ollama returns reasoning either in a separate `thinking` field or inline in
194
+ # `content`; in both cases we show only the final answer.
195
+ text = strip_thinking(msg.get("content") or "")
196
+ if text:
197
+ yield Chunk(text=text)
198
+ for call in msg.get("tool_calls", []) or []:
199
+ fn = call.get("function", {})
200
+ args = fn.get("arguments", {})
201
+ if isinstance(args, str): # some models return JSON-as-string
202
+ try:
203
+ args = json.loads(args)
204
+ except json.JSONDecodeError:
205
+ args = {}
206
+ yield Chunk(tool_call=ToolCall(name=fn.get("name", ""), arguments=args or {}))
gini/agent/loop.py ADDED
@@ -0,0 +1,258 @@
1
+ """The agent loop: backend + tool registry -> a working assistant/tutor brain.
2
+
3
+ Runs the standard cycle — model proposes tool calls, we execute them against the shared
4
+ registry, feed results back, repeat until the model answers in prose. Supports native
5
+ tool calling and a JSON-action fallback for models that can't call tools, so GINI isn't
6
+ hostage to one model's tool-use quality.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from collections.abc import Callable
13
+
14
+ from .llm.backend import LLMBackend, Message, ToolCall
15
+ from .tools.registry import ToolRegistry
16
+
17
+ ASSISTANT_NAME = "GINI"
18
+
19
+ SYSTEM_PROMPT = (
20
+ f"You are {ASSISTANT_NAME}, a friendly teaching assistant living inside the GINI "
21
+ "gBuilder lab, where students build and run computer-network and cloud topologies. "
22
+ "Before each question you are given the LIVE state of the student's canvas — every "
23
+ "device, its IP addresses and subnets, and how things connect. Treat that state as "
24
+ "ground truth and answer from it directly; you do NOT need a tool to see the canvas. "
25
+ "Refer to devices by their names (e.g. R1, M1). Help students build, inspect, and "
26
+ "understand their topology, and explain networking/cloud concepts clearly and "
27
+ "concisely.\n"
28
+ "You can TEACH ON THE CANVAS — when explaining how something works, use the present "
29
+ "tools to direct the student's eye: `spotlight` a device to focus on it, `highlight` "
30
+ "a set, `callout` to anchor a short note on a device, `narrate` a line of teaching, "
31
+ "and `trace_path` + `animate_packet` to show how a packet flows between two hosts. "
32
+ "Prefer showing on the canvas over long text. Use build tools only when asked to "
33
+ "change the topology: `add_device` (args: type_key e.g. 'router'/'switch'/'host', and "
34
+ "optional name), `connect_devices` (args: a, b), `set_property`, `remove_device`. "
35
+ "`callout` needs a device and text. When no tool is needed, answer in plain language.\n"
36
+ "If you cannot call tools natively, emit JSON objects of the form "
37
+ '{\"tool\": \"<name>\", \"args\": { ... }} — and write any explanation as plain prose, '
38
+ "NOT inside the JSON. Never write tool calls as prose (e.g. `add_device type_key=...`).\n"
39
+ "GROUNDING: GINI has a specific, fixed set of elements — the ones listed in the context "
40
+ "below. Answer USING those elements and the GINI knowledge provided; treat that "
41
+ "knowledge and the canvas as the only source of truth. EVERY element you name must "
42
+ "appear verbatim in that GINI elements list; if something is not listed, it does NOT "
43
+ "exist in GINI — never mention or invent it, even if it is common in the real world "
44
+ "(e.g. there is no 'SDN dashboard'). If GINI has no element for something, say so "
45
+ "plainly. Do NOT reach for generic infrastructure or other products from your training. "
46
+ "When a live xv6 Machine state card is provided, the SAME rule applies to the kernel: "
47
+ "reason only about the processes, pids, states, registers and stack frames shown in that "
48
+ "card — it is this student's actual running kernel. Do not invent pids or values that "
49
+ "aren't there; if a detail isn't in the card, say it isn't shown rather than guessing.\n"
50
+ "Format answers in Markdown."
51
+ )
52
+
53
+
54
+ def _extract_json_objects(text: str) -> list[dict]:
55
+ """Pull balanced-brace JSON objects out of free text (handles nested args)."""
56
+ objs: list[dict] = []
57
+ depth = 0
58
+ start: int | None = None
59
+ for i, ch in enumerate(text or ""):
60
+ if ch == "{":
61
+ if depth == 0:
62
+ start = i
63
+ depth += 1
64
+ elif ch == "}" and depth > 0:
65
+ depth -= 1
66
+ if depth == 0 and start is not None:
67
+ try:
68
+ objs.append(json.loads(text[start:i + 1]))
69
+ except json.JSONDecodeError:
70
+ pass
71
+ start = None
72
+ return objs
73
+
74
+
75
+ _TOOL_TAG_RE = re.compile(r"<\s*(tool_call|tool|json|function_call)\s*>.*?</\s*\1\s*>",
76
+ re.S | re.I)
77
+
78
+ # Some small models write tool calls as PROSE (`add_device type_key='host' name='F1'`),
79
+ # which is neither native nor JSON, so it slips past the tag/JSON strippers and leaks into
80
+ # chat. Strip those pseudo-calls: a full build/present tool name, one optional device-ref
81
+ # arg (R1/F1/…), then key=value pairs. We DON'T match bare words as args (so following prose
82
+ # like "… Now that …" is never eaten) and we leave callout/narrate alone (their text is the
83
+ # model teaching). Bare verb aliases (add/connect) are excluded — too common in real prose.
84
+ _PSEUDO_CALL_RE = re.compile(
85
+ r"\b(?:add_device|connect_devices|set_property|remove_device|spotlight|highlight|"
86
+ r"animate_packet|trace_path|clear_stage|inspect_device|get_topology|summarize_topology|"
87
+ r"explain_topology|explain_device|explain_element|list_device_types)\b"
88
+ r"(?:"
89
+ r"[ \t]+[A-Za-z]{1,4}\d+(?:[ \t]+\w+=(?:'[^']*'|\"[^\"]*\"|[^\s]+))*" # ref [+ key=val…]
90
+ r"|(?:[ \t]+\w+=(?:'[^']*'|\"[^\"]*\"|[^\s]+))+" # or one+ key=val
91
+ r")") # require args, so a bare mention of a tool name in prose is NOT stripped
92
+
93
+
94
+ def _strip_tool_objects(text: str) -> str:
95
+ """Remove top-level {...} JSON objects that are tool actions (have a 'tool' key)."""
96
+ spans: list[tuple[int, int]] = []
97
+ depth = 0
98
+ start: int | None = None
99
+ for i, ch in enumerate(text):
100
+ if ch == "{":
101
+ if depth == 0:
102
+ start = i
103
+ depth += 1
104
+ elif ch == "}" and depth > 0:
105
+ depth -= 1
106
+ if depth == 0 and start is not None:
107
+ try:
108
+ obj = json.loads(text[start:i + 1])
109
+ if isinstance(obj, dict) and "tool" in obj:
110
+ spans.append((start, i + 1))
111
+ except json.JSONDecodeError:
112
+ pass
113
+ start = None
114
+ if not spans:
115
+ return text
116
+ out, prev = [], 0
117
+ for a, b in spans:
118
+ out.append(text[prev:a])
119
+ prev = b
120
+ out.append(text[prev:])
121
+ return "".join(out)
122
+
123
+
124
+ def visible_text(raw: str) -> str:
125
+ """The user-facing prose from a model reply: keep the natural language and the *text*
126
+ of any callout/narrate calls (that's the model talking), but drop all tool-call syntax
127
+ — `<tool_call>`/`<json>` tag blocks and bare {"tool": …} action objects. So the chat
128
+ shows what the model SAID, while the loop still executes what it DID."""
129
+ raw = raw or ""
130
+ spoken: list[str] = []
131
+ for obj in _extract_json_objects(raw):
132
+ if obj.get("tool") in ("callout", "narrate"):
133
+ args = obj.get("args") or {}
134
+ t = args.get("text") or args.get("line") or args.get("note")
135
+ if t:
136
+ spoken.append(str(t).strip())
137
+ prose = _strip_tool_objects(_TOOL_TAG_RE.sub(" ", raw))
138
+ prose = _PSEUDO_CALL_RE.sub(" ", prose) # prose-style tool calls
139
+ prose = re.sub(r"`{3}.*?`{3}", " ", prose, flags=re.S) # stray code fences
140
+ prose = re.sub(r"[ \t]*\n[ \t]*", "\n", prose)
141
+ prose = re.sub(r"[ \t]{2,}", " ", prose).strip()
142
+ parts, seen = [], set()
143
+ for p in ([prose] if prose else []) + spoken:
144
+ if p and p not in seen:
145
+ parts.append(p)
146
+ seen.add(p)
147
+ return "\n".join(parts)
148
+
149
+
150
+ class AgentLoop:
151
+ def __init__(self, backend: LLMBackend, registry: ToolRegistry,
152
+ system_prompt: str = SYSTEM_PROMPT, max_steps: int = 6,
153
+ context_provider: Callable[[], str] | None = None,
154
+ max_history: int = 30) -> None:
155
+ self.backend = backend
156
+ self.registry = registry
157
+ self.max_steps = max_steps
158
+ # returns a fresh snapshot of the live canvas; injected every turn
159
+ self.context_provider = context_provider
160
+ # keep the recent conversation (so the tutor remembers what was discussed) but
161
+ # bound it so it never overflows the model's context window — a sliding window
162
+ # over whole turns. The live canvas context is re-injected each turn regardless.
163
+ self.max_history = max_history
164
+ self.history: list[Message] = [Message("system", system_prompt)]
165
+ # a per-project 'brief' — the teacher's framing for this lab. Always injected as a
166
+ # system message so every answer is shaped by it (set by the Ask GINI panel on
167
+ # project load). Empty by default.
168
+ self.brief: str = ""
169
+ # the Ask GINI pipeline sets this each turn to the fully-assembled grounded context
170
+ # (always-on index + canvas + session knowledge + retrieved cards). When set it
171
+ # REPLACES the bare canvas snapshot, so grounding all flows through one block.
172
+ self.extra_context: str = ""
173
+
174
+ def _trim(self) -> None:
175
+ if len(self.history) <= self.max_history:
176
+ return
177
+ cut = len(self.history) - self.max_history
178
+ # advance to the next 'user' message so we don't orphan assistant/tool turns
179
+ while cut < len(self.history) and self.history[cut].role != "user":
180
+ cut += 1
181
+ if cut < len(self.history):
182
+ self.history = [self.history[0]] + self.history[cut:]
183
+
184
+ def _messages(self) -> list[Message]:
185
+ """Base system prompt + a FRESH canvas-state message + the conversation.
186
+ The context is regenerated each call (never stored), so the model always sees
187
+ the current topology and the history doesn't fill up with stale snapshots."""
188
+ msgs = [self.history[0]]
189
+ if self.brief:
190
+ msgs.append(Message("system",
191
+ "Project brief — the teacher's framing for this lab. Keep your "
192
+ "guidance consistent with it:\n" + self.brief))
193
+ if self.extra_context:
194
+ msgs.append(Message("system", self.extra_context)) # pipeline-assembled
195
+ elif self.context_provider is not None:
196
+ try:
197
+ ctx = self.context_provider()
198
+ except Exception:
199
+ ctx = ""
200
+ if ctx:
201
+ msgs.append(Message("system", "Current canvas (ground truth):\n" + ctx))
202
+ return msgs + self.history[1:]
203
+
204
+ def send(self, user_text: str, on_text: Callable[[str], None] | None = None) -> str:
205
+ """Run a full turn (possibly several tool round-trips) and return final prose."""
206
+ self.history.append(Message("user", user_text))
207
+ final = ""
208
+ for _ in range(self.max_steps):
209
+ text = ""
210
+ calls: list[ToolCall] = []
211
+ for chunk in self.backend.chat(self._messages(), tools=self.registry.openai_tools()):
212
+ if chunk.text:
213
+ text += chunk.text
214
+ if on_text:
215
+ on_text(chunk.text)
216
+ if chunk.tool_call:
217
+ calls.append(chunk.tool_call)
218
+
219
+ # fallback: a model with no native tools may emit a JSON action in text
220
+ if not calls:
221
+ calls = self._parse_json_actions(text)
222
+
223
+ self.history.append(Message("assistant", text, tool_calls=calls))
224
+
225
+ if not calls:
226
+ final = text
227
+ break
228
+
229
+ for tc in calls:
230
+ result = self.registry.execute(tc.name, tc.arguments)
231
+ self.history.append(Message("tool", json.dumps(result, default=str), name=tc.name))
232
+ self._trim() # keep recent context, bound the window
233
+ return final
234
+
235
+ # forgiving aliases — local models follow the prose prompt and use short verb names /
236
+ # 'type' instead of the registry's exact 'add_device' / 'type_key', so normalize them
237
+ # (otherwise the call silently doesn't match and nothing happens).
238
+ _TOOL_ALIASES = {
239
+ "add": "add_device", "create": "add_device", "place": "add_device",
240
+ "connect": "connect_devices", "link": "connect_devices", "wire": "connect_devices",
241
+ "remove": "remove_device", "delete": "remove_device",
242
+ "rename": "set_property", "set": "set_property",
243
+ }
244
+ _ARG_ALIASES = {"type": "type_key", "kind": "type_key", "device_type": "type_key"}
245
+
246
+ def _parse_json_actions(self, text: str) -> list[ToolCall]:
247
+ calls: list[ToolCall] = []
248
+ names = self.registry.names()
249
+ for obj in _extract_json_objects(text):
250
+ raw = obj.get("tool")
251
+ if not raw:
252
+ continue
253
+ name = self._TOOL_ALIASES.get(raw, raw)
254
+ if name not in names:
255
+ continue
256
+ args = {self._ARG_ALIASES.get(k, k): v for k, v in (obj.get("args") or {}).items()}
257
+ calls.append(ToolCall(name=name, arguments=args))
258
+ return calls
@@ -0,0 +1,86 @@
1
+ """MCP server exposing the shared tool registry to external AI agents.
2
+
3
+ The tool *behavior* lives in one place — `tools.registry` — and is shared with the
4
+ in-app agent loop. Here we publish those same tools over MCP with explicit signatures
5
+ (so clients get correct schemas), each delegating to `registry.execute`. Install the
6
+ optional dep with: pip install gini-gbuilder[agent].
7
+
8
+ Run standalone: python -m gini.agent.mcp_server
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from ..app import AppContext
15
+ from .api import GiniAPI
16
+ from .tools.registry import ToolRegistry, build_registry
17
+
18
+
19
+ def build_server(api: GiniAPI, registry: ToolRegistry | None = None):
20
+ try:
21
+ from mcp.server.fastmcp import FastMCP
22
+ except ImportError as e: # pragma: no cover
23
+ raise RuntimeError("MCP server needs 'mcp'. Install: pip install gini-gbuilder[agent]") from e
24
+
25
+ reg = registry or build_registry(api)
26
+ server = FastMCP("gini-gbuilder")
27
+
28
+ def call(name: str, **kwargs) -> str:
29
+ return json.dumps(reg.execute(name, kwargs), default=str)
30
+
31
+ @server.tool()
32
+ def list_device_types() -> str:
33
+ """List every device/element type GINI can place (networking + cloud)."""
34
+ return call("list_device_types")
35
+
36
+ @server.tool()
37
+ def add_device(type_key: str, name: str = "", x: float = 0.0, y: float = 0.0) -> str:
38
+ """Add a device. type_key e.g. 'router','switch','vpc','container','instance'."""
39
+ return call("add_device", type_key=type_key, name=name, x=x, y=y)
40
+
41
+ @server.tool()
42
+ def connect_devices(a: str, b: str, label: str = "") -> str:
43
+ """Create a link between two devices (by name or id)."""
44
+ return call("connect_devices", a=a, b=b, label=label)
45
+
46
+ @server.tool()
47
+ def set_property(device: str, key: str, value: str) -> str:
48
+ """Set a property on a device (by name or id)."""
49
+ return call("set_property", device=device, key=key, value=value)
50
+
51
+ @server.tool()
52
+ def inspect_device(device: str) -> str:
53
+ """Inspect a device's type, properties, neighbors, and degree."""
54
+ return call("inspect_device", device=device)
55
+
56
+ @server.tool()
57
+ def get_topology() -> str:
58
+ """Return the full topology as JSON (devices + links)."""
59
+ return call("get_topology")
60
+
61
+ @server.tool()
62
+ def summarize_topology() -> str:
63
+ """Counts of devices, links, and categories."""
64
+ return call("summarize_topology")
65
+
66
+ @server.tool()
67
+ def explain_topology() -> str:
68
+ """Explain the whole topology in plain language for a student."""
69
+ return call("explain_topology")
70
+
71
+ @server.tool()
72
+ def explain_device(device: str) -> str:
73
+ """Explain a single device in plain language for a student."""
74
+ return call("explain_device", device=device)
75
+
76
+ return server
77
+
78
+
79
+ def main() -> None: # pragma: no cover
80
+ ctx = AppContext()
81
+ server = build_server(GiniAPI(ctx))
82
+ server.run()
83
+
84
+
85
+ if __name__ == "__main__": # pragma: no cover
86
+ main()