fcling 0.1.0__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 (75) hide show
  1. fcli/__init__.py +1 -0
  2. fcli/_version.py +24 -0
  3. fcli/agent.py +266 -0
  4. fcli/checks.py +977 -0
  5. fcli/cli.py +1022 -0
  6. fcli/collect.py +181 -0
  7. fcli/delta.py +125 -0
  8. fcli/engine.py +632 -0
  9. fcli/fabric.py +117 -0
  10. fcli/gnmi/__init__.py +1 -0
  11. fcli/gnmi/client.py +462 -0
  12. fcli/gnmi/path.py +158 -0
  13. fcli/gnmi/proto/__init__.py +0 -0
  14. fcli/gnmi/proto/gnmi.proto +468 -0
  15. fcli/gnmi/proto/gnmi_ext.proto +192 -0
  16. fcli/gnmi/proto/gnmi_ext_pb2.py +70 -0
  17. fcli/gnmi/proto/gnmi_ext_pb2.pyi +141 -0
  18. fcli/gnmi/proto/gnmi_ext_pb2_grpc.py +24 -0
  19. fcli/gnmi/proto/gnmi_pb2.py +124 -0
  20. fcli/gnmi/proto/gnmi_pb2.pyi +344 -0
  21. fcli/gnmi/proto/gnmi_pb2_grpc.py +246 -0
  22. fcli/incidents.py +572 -0
  23. fcli/inventory.py +206 -0
  24. fcli/lens.py +96 -0
  25. fcli/lenses/__init__.py +6 -0
  26. fcli/lenses/common.py +198 -0
  27. fcli/lenses/endpoints.py +141 -0
  28. fcli/lenses/path.py +464 -0
  29. fcli/lenses/service.py +115 -0
  30. fcli/lenses/where.py +229 -0
  31. fcli/llm.py +566 -0
  32. fcli/mcp.py +76 -0
  33. fcli/remote.py +94 -0
  34. fcli/render.py +149 -0
  35. fcli/report.py +210 -0
  36. fcli/reports/__init__.py +6 -0
  37. fcli/reports/bgp.py +136 -0
  38. fcli/reports/bgp_rib.py +381 -0
  39. fcli/reports/common.py +58 -0
  40. fcli/reports/health.py +581 -0
  41. fcli/reports/instances.py +278 -0
  42. fcli/reports/interfaces.py +310 -0
  43. fcli/reports/layer2.py +569 -0
  44. fcli/reports/lldp.py +45 -0
  45. fcli/reports/policy.py +104 -0
  46. fcli/reports/routing.py +449 -0
  47. fcli/reports/services.py +256 -0
  48. fcli/reports/system.py +105 -0
  49. fcli/server.py +503 -0
  50. fcli/service.py +507 -0
  51. fcli/store.py +306 -0
  52. fcli/tools.py +453 -0
  53. fcli/topology.py +319 -0
  54. fcli/tree.py +383 -0
  55. fcli/ui/api.js +133 -0
  56. fcli/ui/app.js +284 -0
  57. fcli/ui/chat.js +156 -0
  58. fcli/ui/history.js +131 -0
  59. fcli/ui/incidents.js +172 -0
  60. fcli/ui/index.html +15 -0
  61. fcli/ui/lenses.js +262 -0
  62. fcli/ui/markdown.js +92 -0
  63. fcli/ui/nav.js +97 -0
  64. fcli/ui/reports.js +147 -0
  65. fcli/ui/style.css +1831 -0
  66. fcli/ui/table.js +141 -0
  67. fcli/ui/topology.js +653 -0
  68. fcli/ui/vendor/README +3 -0
  69. fcli/ui/vendor/preact-htm.module.js +1 -0
  70. fcli/views.py +152 -0
  71. fcling-0.1.0.dist-info/METADATA +187 -0
  72. fcling-0.1.0.dist-info/RECORD +75 -0
  73. fcling-0.1.0.dist-info/WHEEL +4 -0
  74. fcling-0.1.0.dist-info/entry_points.txt +2 -0
  75. fcling-0.1.0.dist-info/licenses/LICENSE +28 -0
fcli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """fcli: what an SR Linux fabric looks like, what is wrong with it, and what changed."""
fcli/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
fcli/agent.py ADDED
@@ -0,0 +1,266 @@
1
+ """The in-app agent: a model, with exactly the tools an MCP client gets.
2
+
3
+ A turn streams the model's answer as it is written, runs the tools it asks
4
+ for - concurrently when it asks for several - over the same
5
+ :class:`~fcli.service.Service` the rest of fcli uses, and loops until it has
6
+ an answer or its rounds run out. Every tool is read-only, so nothing it does
7
+ needs confirming.
8
+
9
+ Which model is :mod:`fcli.llm`'s business: Claude, OpenAI, or anything that
10
+ speaks OpenAI's Chat Completions. The loop is the same for all of them.
11
+
12
+ Conversations live server-side and are only ever appended to, in the
13
+ provider's own format: what the model said goes back to it exactly as it
14
+ came, which is what multi-turn reasoning and prompt caching depend on. The
15
+ system prompt and the tool list never change; what changes from turn to turn -
16
+ the fabric's name, what the user is looking at - rides in the user's message.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import time
25
+ import uuid
26
+ from collections import OrderedDict
27
+ from collections.abc import AsyncIterator, Mapping
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ from .llm import MAX_TOKENS, PAUSE, REFUSAL, Call, Provider, ProviderError, Reply, create
32
+ from .service import Service
33
+ from .tools import TOOLS, ToolError, call
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ #: Tool rounds one turn may take before it is made to answer with what it has.
38
+ MAX_ROUNDS = 12
39
+ #: Retries of a turn whose tool input came back as JSON that could not be parsed.
40
+ JSON_RETRIES = 2
41
+
42
+ SYSTEM_PROMPT = f"""\
43
+ You are fcli's assistant: you help network engineers understand and troubleshoot an SR Linux \
44
+ fabric. You have read-only tools over the fabric's live state and the history fcli keeps; you \
45
+ cannot change anything, and must never suggest that you did.
46
+
47
+ How to work:
48
+ - For "what is wrong", a triage, or any question about health, start with get_incidents. It \
49
+ groups every check's findings by root cause: an incident's root is the cause to explain, its \
50
+ related findings are consequences of it, not separate problems. A 'pattern' incident is one \
51
+ cause repeated in many places - say so rather than listing each place. Severity 'info' is often \
52
+ by design: mention it only if it bears on the question.
53
+ - Then get_history since a little before the incident started, for what changed and when. A \
54
+ change just before a fault is usually its trigger. A change with a 'cause' was likely made by \
55
+ that configuration commit: name the commit and its user.
56
+ - get_drift compares the fabric with a named baseline (list_baselines) or a time: what is \
57
+ different from when it was known good.
58
+ - For anything else, list_reports, then get_report - narrowed to the nodes, labels (inv) and \
59
+ rows (where) the question is about, rather than reading the whole fabric. Sample one or two \
60
+ representative nodes per role, and say which you looked at.
61
+ - For one address, a host or a service, the fabric-wide tools are quicker than reports: \
62
+ locate_address (where an IP or MAC is), trace_path (how traffic would be forwarded from a node or \
63
+ host to an address), service_detail (one mac-vrf/ip-vrf on every node), list_endpoints (the hosts) \
64
+ and get_topology (tiers, cables, clients).
65
+ - Every tool takes at='30m' (or a time) to answer how the fabric was then.
66
+ - Reports are operational state, not configuration. When a question needs configuration you \
67
+ cannot see, say what to check on the node instead.
68
+ - If a tool returns an error, do not repeat the same call: change approach, or answer with what \
69
+ you have.
70
+ - You get at most {MAX_ROUNDS} rounds of tool calls per question; leave room to answer. A \
71
+ partial answer that says what you checked beats no answer.
72
+
73
+ How to answer: lead with the conclusion, then the evidence - node names, the fields and values \
74
+ you used, and times as the tools give them. Be concise; plain markdown, short lists, a small \
75
+ table where it helps. Never invent a value you did not read."""
76
+
77
+
78
+ def tool_definitions() -> list[dict[str, Any]]:
79
+ """The tools, in a fixed order: a cached prefix depends on it."""
80
+ return [
81
+ {"name": t.name, "description": t.description, "input_schema": t.input_schema} for t in TOOLS.values()
82
+ ]
83
+
84
+
85
+ @dataclass
86
+ class Conversation:
87
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
88
+ #: Append-only: what was said, exactly as it was said.
89
+ messages: list[dict[str, Any]] = field(default_factory=list)
90
+ created: float = field(default_factory=time.time)
91
+ used: float = field(default_factory=time.time)
92
+ #: One turn at a time: two at once would interleave their messages.
93
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
94
+ #: The provider its messages are written for: set by the first turn.
95
+ provider: str = ""
96
+
97
+
98
+ class Conversations:
99
+ """The conversations a server keeps, the least recently used dropped beyond *limit*."""
100
+
101
+ def __init__(self, limit: int = 200) -> None:
102
+ self.limit = limit
103
+ self._items: OrderedDict[str, Conversation] = OrderedDict()
104
+
105
+ def get(self, id_: str | None) -> Conversation:
106
+ if id_ and id_ in self._items:
107
+ self._items.move_to_end(id_)
108
+ conversation = self._items[id_]
109
+ else:
110
+ conversation = Conversation()
111
+ self._items[conversation.id] = conversation
112
+ while len(self._items) > self.limit:
113
+ self._items.popitem(last=False)
114
+ conversation.used = time.time()
115
+ return conversation
116
+
117
+
118
+ def _context_line(fabric: str, context: Mapping[str, Any] | None) -> str:
119
+ parts = [f"fabric {fabric}"] if fabric else []
120
+ for key, value in (context or {}).items():
121
+ if value:
122
+ parts.append(f"{key} {value}")
123
+ return f"[{'; '.join(parts)}]\n\n" if parts else ""
124
+
125
+
126
+ class Agent:
127
+ def __init__(
128
+ self,
129
+ service: Service,
130
+ *,
131
+ fabric: str = "",
132
+ provider: str | None = None,
133
+ model: str | None = None,
134
+ effort: str | None = None,
135
+ client: Any = None,
136
+ ) -> None:
137
+ self.service = service
138
+ self.fabric = fabric
139
+ self.llm: Provider = create(provider, model, effort, client)
140
+ self._tools = tool_definitions()
141
+
142
+ @property
143
+ def provider(self) -> str:
144
+ return self.llm.name
145
+
146
+ @property
147
+ def model(self) -> str:
148
+ return self.llm.model
149
+
150
+ @property
151
+ def effort(self) -> str | None:
152
+ return self.llm.effort
153
+
154
+ async def turn(
155
+ self, conversation: Conversation, question: str, context: Mapping[str, Any] | None = None
156
+ ) -> AsyncIterator[dict[str, Any]]:
157
+ """Answer *question* in *conversation*, as events: ``text``, ``tool``,
158
+ ``tool_result``, and last ``done`` or ``error``."""
159
+ async with conversation.lock:
160
+ if conversation.provider and conversation.provider != self.provider:
161
+ yield {
162
+ "type": "error",
163
+ "message": f"this conversation is with {conversation.provider}, "
164
+ f"not {self.provider}: start a new one",
165
+ }
166
+ return
167
+ conversation.provider = self.provider
168
+ async for event in self._turn(conversation, question, context):
169
+ yield event
170
+
171
+ async def _turn(
172
+ self, conversation: Conversation, question: str, context: Mapping[str, Any] | None
173
+ ) -> AsyncIterator[dict[str, Any]]:
174
+ messages = conversation.messages
175
+ messages.append(self.llm.user(_context_line(self.fabric, context) + question))
176
+ usage = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0}
177
+ rounds = 0
178
+ retries = 0
179
+ while True:
180
+ try:
181
+ reply: Reply | None = None
182
+ async for kind, payload in self.llm.stream(
183
+ SYSTEM_PROMPT, self._tools, messages, rounds >= MAX_ROUNDS
184
+ ):
185
+ if kind == "text":
186
+ yield {"type": "text", "text": payload}
187
+ else:
188
+ reply = payload
189
+ except ValueError as exc:
190
+ if isinstance(exc, ProviderError):
191
+ yield {"type": "error", "message": str(exc)}
192
+ return
193
+ # Tool input the SDK could not parse at all: no call to answer,
194
+ # so the turn is asked again.
195
+ retries += 1
196
+ if retries > JSON_RETRIES:
197
+ yield {"type": "error", "message": "the model's tool call could not be read"}
198
+ return
199
+ continue
200
+ except Exception as exc: # the API's own errors, said as they are
201
+ yield {"type": "error", "message": self.llm.error(exc)}
202
+ return
203
+ retries = 0
204
+ assert reply is not None
205
+ for k, v in reply.usage.items():
206
+ usage[k] = usage.get(k, 0) + v
207
+ messages.extend(reply.messages)
208
+ if reply.stop == PAUSE:
209
+ continue
210
+ if reply.stop == REFUSAL:
211
+ yield {"type": "error", "message": "the model declined to answer this"}
212
+ if reply.calls:
213
+ messages.extend(self.llm.results(_unrun(reply.calls, "the turn was declined")))
214
+ return
215
+ if not reply.calls:
216
+ yield {
217
+ "type": "done",
218
+ "stop": reply.stop,
219
+ "rounds": rounds,
220
+ "usage": usage,
221
+ "conversation": conversation.id,
222
+ }
223
+ return
224
+ if reply.stop == MAX_TOKENS:
225
+ # A tool input cut off mid-way can parse as a valid partial one: never run it.
226
+ messages.extend(self.llm.results(_unrun(reply.calls, "the call was cut off")))
227
+ yield {"type": "error", "message": "the answer ran past its length limit"}
228
+ return
229
+ rounds += 1
230
+ for c in reply.calls:
231
+ yield {"type": "tool", "id": c.id, "name": c.name, "input": c.input}
232
+ results = await asyncio.gather(*(self._run(c) for c in reply.calls))
233
+ for c, (content, error) in zip(reply.calls, results, strict=True):
234
+ yield {
235
+ "type": "tool_result",
236
+ "id": c.id,
237
+ "name": c.name,
238
+ "error": error,
239
+ "size": len(content),
240
+ }
241
+ # Every result of the round, together.
242
+ messages.extend(
243
+ self.llm.results(
244
+ [(c, content, error) for c, (content, error) in zip(reply.calls, results, strict=True)]
245
+ )
246
+ )
247
+
248
+ async def _run(self, c: Call) -> tuple[str, bool]:
249
+ if c.input is None:
250
+ return "the arguments were not valid JSON: call again with a JSON object", True
251
+ try:
252
+ return await call(self.service, c.name, c.input), False
253
+ except ToolError as exc:
254
+ return str(exc), True
255
+ except Exception as exc:
256
+ logger.exception("tool %s failed", c.name)
257
+ return f"the tool failed: {type(exc).__name__}: {exc}", True
258
+
259
+
260
+ def _unrun(calls: list[Call], why: str) -> list[tuple[Call, str, bool]]:
261
+ """Results for calls that were not run, so the conversation stays well-formed."""
262
+ return [(c, f"not run: {why}", True) for c in calls]
263
+
264
+
265
+ def as_json(event: Mapping[str, Any]) -> str:
266
+ return json.dumps(event, separators=(",", ":"), default=str)