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.
- fcli/__init__.py +1 -0
- fcli/_version.py +24 -0
- fcli/agent.py +266 -0
- fcli/checks.py +977 -0
- fcli/cli.py +1022 -0
- fcli/collect.py +181 -0
- fcli/delta.py +125 -0
- fcli/engine.py +632 -0
- fcli/fabric.py +117 -0
- fcli/gnmi/__init__.py +1 -0
- fcli/gnmi/client.py +462 -0
- fcli/gnmi/path.py +158 -0
- fcli/gnmi/proto/__init__.py +0 -0
- fcli/gnmi/proto/gnmi.proto +468 -0
- fcli/gnmi/proto/gnmi_ext.proto +192 -0
- fcli/gnmi/proto/gnmi_ext_pb2.py +70 -0
- fcli/gnmi/proto/gnmi_ext_pb2.pyi +141 -0
- fcli/gnmi/proto/gnmi_ext_pb2_grpc.py +24 -0
- fcli/gnmi/proto/gnmi_pb2.py +124 -0
- fcli/gnmi/proto/gnmi_pb2.pyi +344 -0
- fcli/gnmi/proto/gnmi_pb2_grpc.py +246 -0
- fcli/incidents.py +572 -0
- fcli/inventory.py +206 -0
- fcli/lens.py +96 -0
- fcli/lenses/__init__.py +6 -0
- fcli/lenses/common.py +198 -0
- fcli/lenses/endpoints.py +141 -0
- fcli/lenses/path.py +464 -0
- fcli/lenses/service.py +115 -0
- fcli/lenses/where.py +229 -0
- fcli/llm.py +566 -0
- fcli/mcp.py +76 -0
- fcli/remote.py +94 -0
- fcli/render.py +149 -0
- fcli/report.py +210 -0
- fcli/reports/__init__.py +6 -0
- fcli/reports/bgp.py +136 -0
- fcli/reports/bgp_rib.py +381 -0
- fcli/reports/common.py +58 -0
- fcli/reports/health.py +581 -0
- fcli/reports/instances.py +278 -0
- fcli/reports/interfaces.py +310 -0
- fcli/reports/layer2.py +569 -0
- fcli/reports/lldp.py +45 -0
- fcli/reports/policy.py +104 -0
- fcli/reports/routing.py +449 -0
- fcli/reports/services.py +256 -0
- fcli/reports/system.py +105 -0
- fcli/server.py +503 -0
- fcli/service.py +507 -0
- fcli/store.py +306 -0
- fcli/tools.py +453 -0
- fcli/topology.py +319 -0
- fcli/tree.py +383 -0
- fcli/ui/api.js +133 -0
- fcli/ui/app.js +284 -0
- fcli/ui/chat.js +156 -0
- fcli/ui/history.js +131 -0
- fcli/ui/incidents.js +172 -0
- fcli/ui/index.html +15 -0
- fcli/ui/lenses.js +262 -0
- fcli/ui/markdown.js +92 -0
- fcli/ui/nav.js +97 -0
- fcli/ui/reports.js +147 -0
- fcli/ui/style.css +1831 -0
- fcli/ui/table.js +141 -0
- fcli/ui/topology.js +653 -0
- fcli/ui/vendor/README +3 -0
- fcli/ui/vendor/preact-htm.module.js +1 -0
- fcli/views.py +152 -0
- fcling-0.1.0.dist-info/METADATA +187 -0
- fcling-0.1.0.dist-info/RECORD +75 -0
- fcling-0.1.0.dist-info/WHEEL +4 -0
- fcling-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|