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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- 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
|
gini/agent/llm/ollama.py
ADDED
|
@@ -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
|
gini/agent/mcp_server.py
ADDED
|
@@ -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()
|