thingagent 0.1.1__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.
- thingagent/__init__.py +26 -0
- thingagent/agent.py +296 -0
- thingagent/channel.py +121 -0
- thingagent/check.py +231 -0
- thingagent/cli.py +815 -0
- thingagent/decide.py +221 -0
- thingagent/grants.py +100 -0
- thingagent/harness.py +958 -0
- thingagent/models.py +79 -0
- thingagent/rules.py +395 -0
- thingagent-0.1.1.dist-info/METADATA +299 -0
- thingagent-0.1.1.dist-info/RECORD +16 -0
- thingagent-0.1.1.dist-info/WHEEL +5 -0
- thingagent-0.1.1.dist-info/entry_points.txt +2 -0
- thingagent-0.1.1.dist-info/licenses/LICENSE +204 -0
- thingagent-0.1.1.dist-info/top_level.txt +1 -0
thingagent/cli.py
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
# Copyright 2026 The thingagent Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""The ``thingagent`` command.
|
|
4
|
+
|
|
5
|
+
``thingagent check CONFIG`` checks the agents and rules against the fleet's TDs.
|
|
6
|
+
``thingagent serve CONFIG`` runs the agents and their rules until stopped.
|
|
7
|
+
``thingagent chat CONFIG --as PERSON`` does the same with a chat in this terminal.
|
|
8
|
+
``thingagent do CONFIG AGENT TASK`` runs until one agent has done one task.
|
|
9
|
+
``thingagent ask CONFIG AGENT PROMPT`` runs until one agent has answered from what
|
|
10
|
+
it can read. ``thingagent generate CONFIG --out DIR`` writes the fleet gateway's
|
|
11
|
+
grants and the broker's topic rules from the same agents, rules and fleet (or, with
|
|
12
|
+
``--gateway``, the Things a fleet gateway serves). ``thingagent gateway GATEWAY``
|
|
13
|
+
serves the fleet's devices on the bus with those grants (see :func:`_serve_fleet`).
|
|
14
|
+
``thingagent mcp CONFIG CLIENT`` serves the fleet to an MCP client over stdio, as
|
|
15
|
+
that client (see :func:`serving`).
|
|
16
|
+
|
|
17
|
+
CONFIG is a JSON file. Paths in it are relative to it. It never holds a secret:
|
|
18
|
+
each ``*_env`` key names the environment variable that holds one, in letters,
|
|
19
|
+
digits and _, not starting with a digit. Only ``fleet`` and ``agents`` are
|
|
20
|
+
required::
|
|
21
|
+
|
|
22
|
+
{"fleet": "tds", "agents": ["agents/agent.json"], "rules": ["rules/rule.json"]}
|
|
23
|
+
|
|
24
|
+
Without a broker, ``fleet`` holds the devices' own TDs, reached over HTTP, MQTT or
|
|
25
|
+
rosbridge, or in process through handlers an installed package registers under
|
|
26
|
+
thingctx's ``thingctx.local_handlers`` entry points, and the gate runs here. Each
|
|
27
|
+
optional entry adds one thing:
|
|
28
|
+
|
|
29
|
+
``broker`` (``host:port``, or ``mqtts://host:port`` for TLS)
|
|
30
|
+
The fleet is reached through its gateways on the bus and the agents are served
|
|
31
|
+
there; ``fleet`` may then be ``"bus"``, the TDs the gateways retain at start,
|
|
32
|
+
apart from any harness's agents and chat channels.
|
|
33
|
+
``tls``
|
|
34
|
+
``{"ca", "cert", "key"}``, each optional: every connection to the broker uses
|
|
35
|
+
TLS, verified with the CA file ``ca`` or else the system's, presenting the
|
|
36
|
+
client certificate ``cert`` and its key ``key`` when given.
|
|
37
|
+
``insecure_bus``
|
|
38
|
+
Use a broker that is not on this machine (127.0.0.0/8, ::1, localhost) without
|
|
39
|
+
TLS. Every password and token then crosses the network in the clear.
|
|
40
|
+
``name``
|
|
41
|
+
The harness's name, in its chat channel's id, so harnesses on one bus do not
|
|
42
|
+
collide. It is thingagent's login user when not given.
|
|
43
|
+
``token_env``
|
|
44
|
+
Each agent and rule calls as itself: a map from its name to the environment
|
|
45
|
+
variable that holds its token, read once at start.
|
|
46
|
+
``logins``, ``login``
|
|
47
|
+
Each agent and rule logs in to the broker under its own user, and thingagent
|
|
48
|
+
under its own. ``logins`` maps an agent or rule name to ``{"user",
|
|
49
|
+
"password_env"}`` and ``login`` is thingagent's. A user must be its token's
|
|
50
|
+
``sub``. A guarded gateway needs them. In the broker's rules, thingagent's user
|
|
51
|
+
serves the agents and the chat channel, and reads the TDs.
|
|
52
|
+
``guard``, ``broker_binds_identity``, ``policy``
|
|
53
|
+
The agents' Things are protected like any Thing a thingctx gateway serves:
|
|
54
|
+
``guard`` holds ``issuers``, ``audience`` and ``jwks_url``, or ``jwks``, a JWKS
|
|
55
|
+
file, and ``policy`` maps a role to grants as ``grants.json`` holds them. An agent
|
|
56
|
+
that can act runs on a broker only when ``broker_binds_identity`` is true, it
|
|
57
|
+
has a login in ``logins``, and ``guard`` and ``policy`` are set.
|
|
58
|
+
``grants``
|
|
59
|
+
Without a broker, the grants file ``generate`` writes, keyed by each name, used
|
|
60
|
+
in place of the grants derived from the agents and rules.
|
|
61
|
+
``open_bus``
|
|
62
|
+
Run agents that can act on a broker that does not bind each login to its
|
|
63
|
+
identity, or with no guard or policy. Anyone on that bus can then replay a token
|
|
64
|
+
read there and act as an agent, or call the agents.
|
|
65
|
+
``verify_broker``
|
|
66
|
+
Before any agent acts, each login sends a request on every request topic of the
|
|
67
|
+
fleet and the agents' own Things under the next login's name, and the broker
|
|
68
|
+
must refuse each; otherwise nothing starts (see :func:`binds_logins`).
|
|
69
|
+
``gateways``, ``callers``
|
|
70
|
+
For ``generate``. ``gateways`` names the broker users of the fleet's gateways:
|
|
71
|
+
a list of users that each serve the whole fleet, or a map from each user to the
|
|
72
|
+
ids of the Things it serves. ``callers`` names other broker users, such as a
|
|
73
|
+
chat adapter, that may send any request, but only under their own name.
|
|
74
|
+
``clients``
|
|
75
|
+
Identities that call the fleet from outside, through ``thingagent mcp``: each is
|
|
76
|
+
``{"name", "reads", "may"}``, like an agent with no model. ``generate`` grants
|
|
77
|
+
them what they read and may take; ``serve`` does not run them.
|
|
78
|
+
``allow``
|
|
79
|
+
Who may ask which agent.
|
|
80
|
+
``timeout``, ``trace``
|
|
81
|
+
How many seconds a call over the bus may take (30 by default), and a JSON Lines
|
|
82
|
+
file that receives every task, answer and SOSA record.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
from __future__ import annotations
|
|
86
|
+
|
|
87
|
+
import argparse
|
|
88
|
+
import asyncio
|
|
89
|
+
import contextlib
|
|
90
|
+
import importlib.util
|
|
91
|
+
import json
|
|
92
|
+
import os
|
|
93
|
+
import re
|
|
94
|
+
import signal
|
|
95
|
+
import sys
|
|
96
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Collection
|
|
97
|
+
from functools import partial
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
from typing import Any, cast
|
|
100
|
+
|
|
101
|
+
from thingctx import TransportError
|
|
102
|
+
from thingctx.authz import GrantSet
|
|
103
|
+
from thingctx.bindings import (
|
|
104
|
+
BindingRegistry,
|
|
105
|
+
LocalBinding,
|
|
106
|
+
MqttBinding,
|
|
107
|
+
MqttTls,
|
|
108
|
+
discover_local_handlers,
|
|
109
|
+
)
|
|
110
|
+
from thingctx.gateways.mosquitto import acl_file
|
|
111
|
+
from thingctx.models.sosa import recorder
|
|
112
|
+
from thingctx.registry import from_args
|
|
113
|
+
from thingctx.thing import thing_slug
|
|
114
|
+
|
|
115
|
+
from thingagent.agent import AGENT_ID, Agent
|
|
116
|
+
from thingagent.check import check, plain_bus_risk
|
|
117
|
+
from thingagent.grants import grants, loaded, saved
|
|
118
|
+
from thingagent.harness import Harness, do_view, guarded_gateway, only_decides, write_trace
|
|
119
|
+
from thingagent.rules import (
|
|
120
|
+
Rule,
|
|
121
|
+
binds_logins,
|
|
122
|
+
broker_address,
|
|
123
|
+
bus_tls,
|
|
124
|
+
client_as,
|
|
125
|
+
not_in_fleet,
|
|
126
|
+
probe_topics,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def retained(
|
|
131
|
+
broker: str,
|
|
132
|
+
topic: str,
|
|
133
|
+
*,
|
|
134
|
+
login: tuple[str, str] | None = None,
|
|
135
|
+
settle: float = 1.0,
|
|
136
|
+
tls: MqttTls | None = None,
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Each JSON message retained on ``topic`` (a filter) at ``broker``, by topic,
|
|
139
|
+
collected for ``settle`` seconds, logged in as ``login`` when given, over
|
|
140
|
+
``tls`` when given, or the system CA store's for an ``mqtts://`` broker."""
|
|
141
|
+
# Optional dependency of thingctx, required by thingagent.
|
|
142
|
+
import paho.mqtt.client as mqtt # noqa: PLC0415
|
|
143
|
+
|
|
144
|
+
found: dict[str, Any] = {}
|
|
145
|
+
|
|
146
|
+
def on_message(_client: Any, _userdata: Any, message: Any) -> None:
|
|
147
|
+
with contextlib.suppress(ValueError):
|
|
148
|
+
found[message.topic] = json.loads(message.payload)
|
|
149
|
+
|
|
150
|
+
host, port, _ = broker_address(broker)
|
|
151
|
+
tls = bus_tls(broker, tls)
|
|
152
|
+
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
|
|
153
|
+
client.on_message = on_message
|
|
154
|
+
if login is not None:
|
|
155
|
+
client.username_pw_set(*login)
|
|
156
|
+
if tls is not None:
|
|
157
|
+
tls.apply(client)
|
|
158
|
+
client.connect(host, port)
|
|
159
|
+
client.subscribe(topic, qos=1)
|
|
160
|
+
client.loop_start()
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.sleep(settle)
|
|
163
|
+
finally:
|
|
164
|
+
client.loop_stop()
|
|
165
|
+
client.disconnect()
|
|
166
|
+
return found
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_OWN_IDS = "urn:thingagent:"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def fleet_from_bus(
|
|
173
|
+
broker: str,
|
|
174
|
+
prefix: str,
|
|
175
|
+
*,
|
|
176
|
+
login: tuple[str, str] | None = None,
|
|
177
|
+
settle: float = 1.0,
|
|
178
|
+
tls: MqttTls | None = None,
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
"""The TDs retained under ``<prefix>/+/td``, apart from any harness's agents and
|
|
181
|
+
chat channels, collected for ``settle`` seconds, logged in as ``login`` when
|
|
182
|
+
given, over ``tls`` when given. A TD :func:`not_in_fleet` refuses is left out,
|
|
183
|
+
and reported on standard error."""
|
|
184
|
+
found = await retained(broker, f"{prefix}/+/td", login=login, settle=settle, tls=tls)
|
|
185
|
+
fleet: list[dict[str, Any]] = []
|
|
186
|
+
for topic, td in found.items():
|
|
187
|
+
if isinstance(td, dict) and str(td.get("id")).startswith(_OWN_IDS):
|
|
188
|
+
continue
|
|
189
|
+
reason = not_in_fleet(topic, td, broker)
|
|
190
|
+
if reason is None:
|
|
191
|
+
fleet.append(td)
|
|
192
|
+
else:
|
|
193
|
+
print(f"thingagent: leaving out the TD retained at {topic}: {reason}", file=sys.stderr)
|
|
194
|
+
return fleet
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def device_bindings(fleet: list[dict[str, Any]], timeout: float | None = None) -> list[Any]:
|
|
198
|
+
"""The bindings that reach the fleet's devices directly: HTTP, MQTT, in
|
|
199
|
+
process handlers installed packages register, and rosbridge when a form uses it.
|
|
200
|
+
``timeout`` bounds each MQTT and rosbridge call when given."""
|
|
201
|
+
bindings = list(BindingRegistry.default(mqtt=True).bindings)
|
|
202
|
+
if timeout is not None:
|
|
203
|
+
bindings = [
|
|
204
|
+
MqttBinding(timeout=timeout) if isinstance(b, MqttBinding) else b for b in bindings
|
|
205
|
+
]
|
|
206
|
+
local = next(b for b in bindings if isinstance(b, LocalBinding))
|
|
207
|
+
for slug, handlers in discover_local_handlers({thing_slug(td["id"]) for td in fleet}).items():
|
|
208
|
+
local.register_thing(slug, handlers)
|
|
209
|
+
hrefs = json.dumps(fleet)
|
|
210
|
+
if '"rosbridge://' in hrefs:
|
|
211
|
+
# Needs thingctx's ros extra, only when the fleet has rosbridge forms.
|
|
212
|
+
from thingctx.bindings.builtin.rosbridge import RosbridgeBinding # noqa: PLC0415
|
|
213
|
+
|
|
214
|
+
bindings.append(RosbridgeBinding(timeout=timeout) if timeout else RosbridgeBinding())
|
|
215
|
+
return bindings
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def guard(settings: dict[str, Any] | None, base: Path) -> Any:
|
|
219
|
+
"""A JWT guard from ``issuers``, ``audience`` and ``jwks_url``, or ``jwks``, a
|
|
220
|
+
JWKS file relative to ``base``; None without settings."""
|
|
221
|
+
if not settings:
|
|
222
|
+
return None
|
|
223
|
+
# Needs thingctx's authz extra, which thingagent depends on.
|
|
224
|
+
from thingctx.identity import JwtGatewayGuard # noqa: PLC0415
|
|
225
|
+
|
|
226
|
+
keys = {"jwks": json.loads((base / settings["jwks"]).read_text())} if "jwks" in settings else {}
|
|
227
|
+
return JwtGatewayGuard(
|
|
228
|
+
issuers=settings["issuers"],
|
|
229
|
+
audience=settings["audience"],
|
|
230
|
+
jwks_url=settings.get("jwks_url"),
|
|
231
|
+
**keys,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _of(name: str, names: Collection[str] | None) -> bool:
|
|
236
|
+
return names is None or name in names
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _env_name(key: str, value: Any) -> str:
|
|
243
|
+
"""``value``, the config key ``key``'s, when it names an environment variable.
|
|
244
|
+
The message names the key, never the value, which may be a secret."""
|
|
245
|
+
if not (isinstance(value, str) and _ENV_NAME.fullmatch(value)):
|
|
246
|
+
raise SystemExit(
|
|
247
|
+
f"thingagent: {key} must name an environment variable, in letters, digits "
|
|
248
|
+
"and _, not starting with a digit"
|
|
249
|
+
)
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _env(key: str, name: str) -> str:
|
|
254
|
+
"""The value of the environment variable ``name``, which the config key ``key``
|
|
255
|
+
holds."""
|
|
256
|
+
if name not in os.environ:
|
|
257
|
+
raise SystemExit(f"thingagent: set the environment variable that {key} names")
|
|
258
|
+
return os.environ[name]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _secret_names(raw: dict[str, Any]) -> None:
|
|
262
|
+
"""Refuse a configuration whose secret keys do not each name an environment
|
|
263
|
+
variable: ``login.password_env``, each ``logins.<name>.password_env`` and each
|
|
264
|
+
``token_env.<name>``."""
|
|
265
|
+
logins = raw.get("logins", {})
|
|
266
|
+
tokens = raw.get("token_env", {})
|
|
267
|
+
if not isinstance(logins, dict) or not isinstance(tokens, dict):
|
|
268
|
+
raise SystemExit("thingagent: logins and token_env each map a name to its entry")
|
|
269
|
+
entries = {f"logins.{name}": entry for name, entry in logins.items()}
|
|
270
|
+
if "login" in raw:
|
|
271
|
+
entries["login"] = raw["login"]
|
|
272
|
+
for key, entry in entries.items():
|
|
273
|
+
given = entry.get("password_env") if isinstance(entry, dict) else None
|
|
274
|
+
_env_name(f"{key}.password_env", given)
|
|
275
|
+
for name, env in tokens.items():
|
|
276
|
+
_env_name(f"token_env.{name}", env)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _tls(raw: dict[str, Any], base: Path) -> MqttTls | None:
|
|
280
|
+
"""The TLS every connection to ``raw``'s broker uses: its ``tls`` entry, files
|
|
281
|
+
relative to ``base``, or the system CA store's for an ``mqtts://`` broker."""
|
|
282
|
+
entry = raw.get("tls")
|
|
283
|
+
given = None
|
|
284
|
+
if entry is not None:
|
|
285
|
+
if not isinstance(entry, dict) or set(entry) - {"ca", "cert", "key"}:
|
|
286
|
+
raise SystemExit("thingagent: tls takes only ca, cert and key, each a file")
|
|
287
|
+
files = {k: str(base / entry[k]) for k in ("ca", "cert", "key") if k in entry}
|
|
288
|
+
given = MqttTls(
|
|
289
|
+
ca_certs=files.get("ca"), certfile=files.get("cert"), keyfile=files.get("key")
|
|
290
|
+
)
|
|
291
|
+
return bus_tls(raw["broker"], given) if raw.get("broker") else given
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _refuse_plain(raw: dict[str, Any], tls: MqttTls | None) -> None:
|
|
295
|
+
"""Refuse ``raw``'s broker before anything connects when :func:`plain_bus_risk`
|
|
296
|
+
finds it may not be used."""
|
|
297
|
+
insecure = bool(raw.get("insecure_bus", False))
|
|
298
|
+
risk = plain_bus_risk(raw["broker"], tls=tls is not None, insecure_bus=insecure)
|
|
299
|
+
if risk:
|
|
300
|
+
raise SystemExit(f"thingagent: {risk}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _verify(
|
|
304
|
+
broker: str, topics: list[str], logins: list[tuple[str, str]], tls: MqttTls | None
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Refuse to start unless the broker refuses each of ``logins`` a request under
|
|
307
|
+
another name (see :func:`binds_logins`)."""
|
|
308
|
+
if not logins:
|
|
309
|
+
raise SystemExit("thingagent: verify_broker has no login to check")
|
|
310
|
+
try:
|
|
311
|
+
published = await binds_logins(broker, topics, logins, tls=tls)
|
|
312
|
+
except TransportError as exc:
|
|
313
|
+
raise SystemExit(f"thingagent: verify_broker could not log in: {exc}") from None
|
|
314
|
+
if published:
|
|
315
|
+
raise SystemExit("\n".join(f"thingagent: {why}" for why in published))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class Config:
|
|
319
|
+
def __init__(self, path: Path) -> None:
|
|
320
|
+
self.raw: dict[str, Any] = json.loads(path.read_text())
|
|
321
|
+
self.base = path.parent
|
|
322
|
+
self.broker: str | None = self.raw.get("broker")
|
|
323
|
+
self.prefix: str = self.raw.get("prefix", "tc")
|
|
324
|
+
_secret_names(self.raw)
|
|
325
|
+
|
|
326
|
+
def agents(self) -> list[Agent]:
|
|
327
|
+
return [Agent.load(self.base / p) for p in self.raw.get("agents", [])]
|
|
328
|
+
|
|
329
|
+
def rules(self) -> list[Rule]:
|
|
330
|
+
return [Rule.load(self.base / p) for p in self.raw.get("rules", [])]
|
|
331
|
+
|
|
332
|
+
def clients(self) -> list[Agent]:
|
|
333
|
+
return [Agent.from_dict(raw) for raw in self.raw.get("clients", [])]
|
|
334
|
+
|
|
335
|
+
async def fleet(self) -> list[dict[str, Any]]:
|
|
336
|
+
source = self.raw["fleet"]
|
|
337
|
+
if source == "bus":
|
|
338
|
+
if not self.broker:
|
|
339
|
+
raise SystemExit('thingagent: "fleet": "bus" needs a broker')
|
|
340
|
+
_refuse_plain(self.raw, self.tls())
|
|
341
|
+
return await fleet_from_bus(
|
|
342
|
+
self.broker, self.prefix, login=self.login(), tls=self.tls()
|
|
343
|
+
)
|
|
344
|
+
sources = [source] if isinstance(source, str) else source
|
|
345
|
+
return await _tds(sources, self.base)
|
|
346
|
+
|
|
347
|
+
def name(self) -> str | None:
|
|
348
|
+
"""The harness's name: ``name``, or else thingagent's login user."""
|
|
349
|
+
return self.raw.get("name") or (self.raw.get("login") or {}).get("user")
|
|
350
|
+
|
|
351
|
+
def own(self) -> list[dict[str, Any]]:
|
|
352
|
+
"""The TDs this harness serves: its agents and its chat channel."""
|
|
353
|
+
return Harness(self.agents(), fleet=[], name=self.name()).own_tds()
|
|
354
|
+
|
|
355
|
+
def tokens(self, names: Collection[str] | None = None) -> dict[str, str]:
|
|
356
|
+
"""Each token, or only those of ``names``, from the environment. Raises
|
|
357
|
+
``SystemExit`` when one is not set."""
|
|
358
|
+
return {
|
|
359
|
+
name: _env(f"token_env.{name}", env)
|
|
360
|
+
for name, env in self.raw.get("token_env", {}).items()
|
|
361
|
+
if _of(name, names)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
def logins(self, names: Collection[str] | None = None) -> dict[str, tuple[str, str]]:
|
|
365
|
+
"""Each login, or only those of ``names``, its password from the environment."""
|
|
366
|
+
return {
|
|
367
|
+
name: (entry["user"], _env(f"logins.{name}.password_env", entry["password_env"]))
|
|
368
|
+
for name, entry in self.raw.get("logins", {}).items()
|
|
369
|
+
if _of(name, names)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
def login(self) -> tuple[str, str] | None:
|
|
373
|
+
entry = self.raw.get("login")
|
|
374
|
+
return (entry["user"], _env("login.password_env", entry["password_env"])) if entry else None
|
|
375
|
+
|
|
376
|
+
def tls(self) -> MqttTls | None:
|
|
377
|
+
return _tls(self.raw, self.base)
|
|
378
|
+
|
|
379
|
+
def guard(self) -> Any:
|
|
380
|
+
return guard(self.raw.get("guard"), self.base)
|
|
381
|
+
|
|
382
|
+
def bus(self) -> dict[str, Any]:
|
|
383
|
+
"""What :func:`check` needs to judge whether agents that act may use the bus."""
|
|
384
|
+
return {
|
|
385
|
+
"broker": self.broker,
|
|
386
|
+
"broker_binds_identity": bool(self.raw.get("broker_binds_identity", False)),
|
|
387
|
+
"logins": set(self.raw.get("logins", {})),
|
|
388
|
+
"guard": bool(self.raw.get("guard")),
|
|
389
|
+
"policy": "policy" in self.raw,
|
|
390
|
+
"open_bus": bool(self.raw.get("open_bus", False)),
|
|
391
|
+
"tls": self.tls() is not None,
|
|
392
|
+
"insecure_bus": bool(self.raw.get("insecure_bus", False)),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
def gateways(self, fleet: list[dict[str, Any]]) -> dict[str, list[str]]:
|
|
396
|
+
"""Each fleet gateway's broker user, with the ids of the Things it serves."""
|
|
397
|
+
entry = self.raw.get("gateways", [])
|
|
398
|
+
if isinstance(entry, dict):
|
|
399
|
+
return {user: list(things) for user, things in entry.items()}
|
|
400
|
+
if isinstance(entry, list):
|
|
401
|
+
return {user: [td["id"] for td in fleet] for user in entry}
|
|
402
|
+
raise SystemExit("thingagent: gateways is a list of users or a map from user to Thing ids")
|
|
403
|
+
|
|
404
|
+
def policy(self) -> dict[str, GrantSet] | None:
|
|
405
|
+
return loaded(self.raw["policy"]) if "policy" in self.raw else None
|
|
406
|
+
|
|
407
|
+
def grants(self) -> dict[str, GrantSet] | None:
|
|
408
|
+
"""The grants file ``grants`` names, for the in process gate."""
|
|
409
|
+
if "grants" not in self.raw:
|
|
410
|
+
return None
|
|
411
|
+
return loaded(json.loads((self.base / self.raw["grants"]).read_text()))
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _check(
|
|
415
|
+
config: Config, fleet: list[dict[str, Any]], rules: list[Rule] | None = None
|
|
416
|
+
) -> list[str]:
|
|
417
|
+
return check(
|
|
418
|
+
config.agents(),
|
|
419
|
+
config.rules() if rules is None else rules,
|
|
420
|
+
fleet,
|
|
421
|
+
tokens=set(config.raw.get("token_env", {})),
|
|
422
|
+
allow=config.raw.get("allow"),
|
|
423
|
+
clients=config.clients(),
|
|
424
|
+
**config.bus(),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def _converse(harness: Harness, person: str) -> None:
|
|
429
|
+
"""A chat in this terminal, as ``person``, until end of input."""
|
|
430
|
+
shown = 0
|
|
431
|
+
|
|
432
|
+
async def show() -> None:
|
|
433
|
+
nonlocal shown
|
|
434
|
+
while True:
|
|
435
|
+
for message in harness.channel.outbox[shown:]:
|
|
436
|
+
if message["person"] == person:
|
|
437
|
+
print(f"\n{message['text']}", flush=True)
|
|
438
|
+
shown = len(harness.channel.outbox)
|
|
439
|
+
await asyncio.sleep(0.2)
|
|
440
|
+
|
|
441
|
+
printer = asyncio.ensure_future(show())
|
|
442
|
+
try:
|
|
443
|
+
while True:
|
|
444
|
+
try:
|
|
445
|
+
line = await asyncio.to_thread(input, f"{person}> ")
|
|
446
|
+
except EOFError:
|
|
447
|
+
return
|
|
448
|
+
if line.strip():
|
|
449
|
+
await harness.channel.receive(person, line)
|
|
450
|
+
finally:
|
|
451
|
+
printer.cancel()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@contextlib.asynccontextmanager
|
|
455
|
+
async def running(config: Config, only: str | None = None) -> AsyncIterator[Harness]:
|
|
456
|
+
"""The agents and their rules, started from ``config`` and stopped on exit;
|
|
457
|
+
with ``only``, an agent's name, only the rules that call that agent, and only
|
|
458
|
+
an agent that can chat. Raises ``SystemExit`` with every problem :func:`check`
|
|
459
|
+
reports."""
|
|
460
|
+
agents, rules = config.agents(), config.rules()
|
|
461
|
+
if only is not None:
|
|
462
|
+
rules = [rule for rule in rules if rule.calls.partition("__")[0] == only]
|
|
463
|
+
try:
|
|
464
|
+
for agent in (a for a in agents if a.name == only):
|
|
465
|
+
only_decides(agent)
|
|
466
|
+
except ValueError as exc:
|
|
467
|
+
raise SystemExit(f"thingagent: {exc}") from None
|
|
468
|
+
fleet = await config.fleet()
|
|
469
|
+
problems = _check(config, fleet, rules)
|
|
470
|
+
if problems:
|
|
471
|
+
raise SystemExit("\n".join(problems))
|
|
472
|
+
# Clients call from outside, so their secrets are not needed here.
|
|
473
|
+
served = {*(a.name for a in agents), *(r.name for r in config.rules())}
|
|
474
|
+
tokens, logins = config.tokens(served), config.logins(served)
|
|
475
|
+
login = config.login()
|
|
476
|
+
if config.broker and config.raw.get("verify_broker"):
|
|
477
|
+
probed = [*logins.values(), *([login] if login else [])]
|
|
478
|
+
await _verify(
|
|
479
|
+
config.broker,
|
|
480
|
+
probe_topics([*fleet, *config.own()], config.prefix),
|
|
481
|
+
probed,
|
|
482
|
+
config.tls(),
|
|
483
|
+
)
|
|
484
|
+
trace = config.raw.get("trace")
|
|
485
|
+
timeout = float(config.raw.get("timeout", 30.0))
|
|
486
|
+
harness = Harness(
|
|
487
|
+
agents,
|
|
488
|
+
fleet=fleet,
|
|
489
|
+
broker=config.broker,
|
|
490
|
+
bindings=None if config.broker else device_bindings(fleet, timeout),
|
|
491
|
+
rules=rules,
|
|
492
|
+
tokens=tokens,
|
|
493
|
+
logins=logins,
|
|
494
|
+
login=login,
|
|
495
|
+
allow=config.raw.get("allow"),
|
|
496
|
+
prefix=config.prefix,
|
|
497
|
+
name=config.name(),
|
|
498
|
+
guard=config.guard(),
|
|
499
|
+
broker_binds_identity=config.raw.get("broker_binds_identity", False),
|
|
500
|
+
open_bus=bool(config.raw.get("open_bus", False)),
|
|
501
|
+
insecure_bus=bool(config.raw.get("insecure_bus", False)),
|
|
502
|
+
policy=config.policy(),
|
|
503
|
+
grants=config.grants(),
|
|
504
|
+
tls=config.tls(),
|
|
505
|
+
timeout=timeout,
|
|
506
|
+
trace=config.base / trace if trace else None,
|
|
507
|
+
)
|
|
508
|
+
await harness.start()
|
|
509
|
+
try:
|
|
510
|
+
where = f"on {config.broker}" if config.broker else "in this process"
|
|
511
|
+
print(f"thingagent: running {', '.join(harness.agents)} {where}", file=sys.stderr)
|
|
512
|
+
yield harness
|
|
513
|
+
finally:
|
|
514
|
+
await harness.aclose()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
async def _until_stopped(*work: Awaitable[Any]) -> None:
|
|
518
|
+
"""Wait for the first of ``work`` to finish, or for SIGINT or SIGTERM."""
|
|
519
|
+
stop = asyncio.Event()
|
|
520
|
+
loop = asyncio.get_running_loop()
|
|
521
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
522
|
+
loop.add_signal_handler(sig, stop.set)
|
|
523
|
+
tasks = [asyncio.ensure_future(w) for w in work] + [asyncio.ensure_future(stop.wait())]
|
|
524
|
+
done, pending = await asyncio.wait(tasks, return_when="FIRST_COMPLETED")
|
|
525
|
+
for task in pending:
|
|
526
|
+
task.cancel()
|
|
527
|
+
for task in done:
|
|
528
|
+
task.result()
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def _run(config: Config, person: str | None) -> None:
|
|
532
|
+
async with running(config) as harness:
|
|
533
|
+
await _until_stopped(*([_converse(harness, person)] if person else []))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
async def _ask(config: Config, name: str, prompt: str) -> str:
|
|
537
|
+
"""Run the agents until ``name`` has answered ``prompt`` from what it can read."""
|
|
538
|
+
async with running(config, only=name) as harness:
|
|
539
|
+
if name not in harness.agents:
|
|
540
|
+
raise SystemExit(f"thingagent: no agent named {name}")
|
|
541
|
+
answered = await harness.ask(harness.agents[name], prompt)
|
|
542
|
+
return str(answered["answer"])
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
async def _do(config: Config, name: str, task: str, person: str | None) -> dict[str, Any]:
|
|
546
|
+
"""Run the agents until ``name`` has done ``task``; with ``person``, this
|
|
547
|
+
terminal is that person's chat, so they can approve what the task waits on.
|
|
548
|
+
Only the rules that call ``name`` run, such as one that approves for it."""
|
|
549
|
+
async with running(config, only=name) as harness:
|
|
550
|
+
if name not in harness.agents:
|
|
551
|
+
raise SystemExit(f"thingagent: no agent named {name}")
|
|
552
|
+
done: dict[str, Any] = {}
|
|
553
|
+
|
|
554
|
+
async def work() -> None:
|
|
555
|
+
done.update(await harness.do(harness.agents[name], task, person=person))
|
|
556
|
+
|
|
557
|
+
await _until_stopped(work(), *([_converse(harness, person)] if person else []))
|
|
558
|
+
return done
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@contextlib.asynccontextmanager
|
|
562
|
+
async def serving(config: Config, name: str) -> AsyncIterator[Any]:
|
|
563
|
+
"""An MCP server of the fleet for the client ``name``, closed on exit. The client
|
|
564
|
+
sees only what it reads and may take, each call goes to the fleet's gateway under
|
|
565
|
+
its own token and login, and each is recorded in the trace. Broker only."""
|
|
566
|
+
# The mcp extra, needed only here.
|
|
567
|
+
from thingctx.integrations.mcp import build_mcp_server # noqa: PLC0415
|
|
568
|
+
|
|
569
|
+
client = next((c for c in config.clients() if c.name == name), None)
|
|
570
|
+
if client is None:
|
|
571
|
+
raise SystemExit(f"thingagent: no client named {name}")
|
|
572
|
+
if not config.broker:
|
|
573
|
+
raise SystemExit("thingagent: mcp needs a broker")
|
|
574
|
+
tls = config.tls()
|
|
575
|
+
_refuse_plain(config.raw, tls)
|
|
576
|
+
token, login = config.tokens([name]).get(name), config.logins([name]).get(name)
|
|
577
|
+
fleet = await fleet_from_bus(config.broker, config.prefix, login=login, tls=tls)
|
|
578
|
+
problems = _check(config, fleet)
|
|
579
|
+
if problems:
|
|
580
|
+
raise SystemExit("\n".join(problems))
|
|
581
|
+
trace = config.raw.get("trace")
|
|
582
|
+
actor = f"urn:thingagent:client:{name}"
|
|
583
|
+
record = recorder(partial(_traced, config.base / trace), actor=actor) if trace else None
|
|
584
|
+
timeout = float(config.raw.get("timeout", 30.0))
|
|
585
|
+
tds = [do_view(td, client) for td in fleet]
|
|
586
|
+
calls = client_as(tds, token, login, record=record, timeout=timeout, tls=tls)
|
|
587
|
+
try:
|
|
588
|
+
yield build_mcp_server(calls, name="thingagent", approve_when="declared")
|
|
589
|
+
finally:
|
|
590
|
+
await calls.aclose()
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
async def _mcp(config: Config, name: str) -> None:
|
|
594
|
+
# The mcp extra, needed only here.
|
|
595
|
+
from mcp.server.stdio import stdio_server # noqa: PLC0415
|
|
596
|
+
|
|
597
|
+
async with serving(config, name) as server, stdio_server() as (read, write):
|
|
598
|
+
await server.run(read, write, server.create_initialization_options())
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _load(base: Path, ref: str) -> Callable[[list[dict[str, Any]]], dict[str, Any]]:
|
|
602
|
+
"""The function ``ref`` (``file.py:name``, the file relative to ``base``) names."""
|
|
603
|
+
file, _, name = ref.partition(":")
|
|
604
|
+
spec = importlib.util.spec_from_file_location(Path(file).stem, base / file)
|
|
605
|
+
if spec is None or spec.loader is None or not name:
|
|
606
|
+
raise SystemExit(f"thingagent: {ref} names no function in a Python file")
|
|
607
|
+
module = importlib.util.module_from_spec(spec)
|
|
608
|
+
spec.loader.exec_module(module)
|
|
609
|
+
return cast("Callable[[list[dict[str, Any]]], dict[str, Any]]", getattr(module, name))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
async def _tds(entries: list[str], base: Path) -> list[dict[str, Any]]:
|
|
613
|
+
"""The TDs ``entries`` name: TD files and folders of them, relative to ``base``,
|
|
614
|
+
and the URLs and ``tdd:URL`` directories thingctx's registry reads."""
|
|
615
|
+
remote = ("tdd:", "http://", "https://")
|
|
616
|
+
sources = [e if e.startswith(remote) else str(base / e) for e in entries]
|
|
617
|
+
return await asyncio.to_thread(from_args(sources).fetch)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
async def _things(entries: list[Any], base: Path) -> list[dict[str, Any]]:
|
|
621
|
+
"""The TDs ``entries`` name, as for :func:`_tds`, and ``{"broker", "topic"}``
|
|
622
|
+
for the TDs devices retain on a broker of their own."""
|
|
623
|
+
tds = await _tds([e for e in entries if isinstance(e, str)], base)
|
|
624
|
+
for entry in entries:
|
|
625
|
+
if isinstance(entry, dict):
|
|
626
|
+
found = await retained(entry["broker"], entry["topic"], settle=2.0)
|
|
627
|
+
tds += [td for td in found.values() if isinstance(td, dict) and "id" in td]
|
|
628
|
+
return tds
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _traced(trace: Path, record: Any) -> None:
|
|
632
|
+
write_trace(trace, {"record": record.as_jsonld()})
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
async def _serve_fleet(path: Path) -> None:
|
|
636
|
+
"""Serve the fleet's devices on the bus, as the fleet's gateway, until stopped.
|
|
637
|
+
|
|
638
|
+
The configuration is a JSON file; paths in it are relative to it::
|
|
639
|
+
|
|
640
|
+
{"broker": "127.0.0.1:1885", "things": ["things"],
|
|
641
|
+
"login": {"user": "fleet_gw", "password_env": "FLEET_GW_PASSWORD"},
|
|
642
|
+
"guard": {"issuers": ["..."], "audience": "...", "jwks": "keys/jwks.json"},
|
|
643
|
+
"grants": "policy/grants.json", "trace": "record/gateway.jsonl"}
|
|
644
|
+
|
|
645
|
+
``things`` holds TD files, folders of them, TD URLs, ``tdd:URL`` directories and
|
|
646
|
+
``{"broker", "topic"}`` for the TDs devices retain on a broker of their own.
|
|
647
|
+
``login`` names the broker user and, in ``password_env``, the environment
|
|
648
|
+
variable that holds its password. ``broker``, ``tls`` and ``insecure_bus`` are
|
|
649
|
+
as for the agents; the refusal of a plain link covers ``broker``, not the
|
|
650
|
+
brokers of devices under ``things``. With ``verify_broker`` the gateway starts
|
|
651
|
+
only when the broker refuses its login every request topic of its Things under
|
|
652
|
+
a name no login has. ``grants`` is the file
|
|
653
|
+
``thingagent generate`` writes, keyed by each caller's token ``sub``; a request
|
|
654
|
+
whose arguments fall outside the caller's grant is refused. ``handlers`` maps a
|
|
655
|
+
Thing's slug to ``file.py:name``, a function that takes the fleet's TDs and
|
|
656
|
+
returns that Thing's in process handlers. ``prefix`` and ``timeout`` are as for
|
|
657
|
+
the agents. ``trace`` names a JSON Lines file that receives the SOSA record of
|
|
658
|
+
every call the gateway runs or refuses, naming its caller. With a guard, a
|
|
659
|
+
request runs only when its token names the User Name it was sent under, so the
|
|
660
|
+
broker must stop each login sending as another."""
|
|
661
|
+
raw = json.loads(path.read_text())
|
|
662
|
+
base = path.parent
|
|
663
|
+
_secret_names(raw)
|
|
664
|
+
tls = _tls(raw, base)
|
|
665
|
+
_refuse_plain(raw, tls)
|
|
666
|
+
things = await _things(raw["things"], base)
|
|
667
|
+
if not things:
|
|
668
|
+
raise SystemExit(f"thingagent: {path} names no Thing Descriptions")
|
|
669
|
+
bindings = device_bindings(things, float(raw.get("timeout", 30.0)))
|
|
670
|
+
local = next(b for b in bindings if isinstance(b, LocalBinding))
|
|
671
|
+
for slug, ref in raw.get("handlers", {}).items():
|
|
672
|
+
local.register_thing(slug, _load(base, ref)(things))
|
|
673
|
+
given = loaded(json.loads((base / raw["grants"]).read_text())) if "grants" in raw else None
|
|
674
|
+
checker = guard(raw.get("guard"), base)
|
|
675
|
+
entry = raw.get("login")
|
|
676
|
+
login = (entry["user"], _env("login.password_env", entry["password_env"])) if entry else None
|
|
677
|
+
prefix = raw.get("prefix", "tc")
|
|
678
|
+
if raw.get("verify_broker"):
|
|
679
|
+
await _verify(raw["broker"], probe_topics(things, prefix), [login] if login else [], tls)
|
|
680
|
+
trace = base / raw["trace"] if "trace" in raw else None
|
|
681
|
+
record = recorder(partial(_traced, trace)) if trace else None
|
|
682
|
+
gateway = guarded_gateway(
|
|
683
|
+
things,
|
|
684
|
+
bindings,
|
|
685
|
+
raw["broker"],
|
|
686
|
+
grants=given,
|
|
687
|
+
claim="sub",
|
|
688
|
+
guard=checker,
|
|
689
|
+
record=record,
|
|
690
|
+
login=login,
|
|
691
|
+
prefix=prefix,
|
|
692
|
+
broker_binds_identity=checker is not None,
|
|
693
|
+
tls=tls,
|
|
694
|
+
)
|
|
695
|
+
await gateway.start()
|
|
696
|
+
names = ", ".join(thing_slug(td["id"]) for td in things)
|
|
697
|
+
print(f"thingagent: serving {names} on {raw['broker']}", file=sys.stderr)
|
|
698
|
+
try:
|
|
699
|
+
await _until_stopped()
|
|
700
|
+
finally:
|
|
701
|
+
await gateway.aclose()
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
async def _generate(config: Config, out: Path, gateway: Path | None = None) -> list[Path]:
|
|
705
|
+
"""Write ``grants.json`` and ``acl`` to ``out`` and return their paths.
|
|
706
|
+
|
|
707
|
+
``grants.json`` holds each agent's and rule's grants on the fleet, keyed by its
|
|
708
|
+
broker user (its token's ``sub``), with the JSON Schema that bounds an action's
|
|
709
|
+
arguments where an agent's steps fix them or its ``limits`` bound them (see
|
|
710
|
+
:func:`thingagent.grants.saved`). ``acl`` holds the broker's rules, compiled from
|
|
711
|
+
the same grants. A rule may also call its agent's ``decide`` or ``do``, which go
|
|
712
|
+
over the bus. Each gateway user may serve only its own Things: a fleet gateway
|
|
713
|
+
the fleet's, or those ``gateways`` lists for it; thingagent's own user the agents
|
|
714
|
+
and the chat channel. Given ``gateway``, a fleet gateway's configuration, the
|
|
715
|
+
fleet is the Things it serves, and its user serves them."""
|
|
716
|
+
served: dict[str, Any] = json.loads(gateway.read_text()) if gateway else {}
|
|
717
|
+
fleet = await (_things(served["things"], gateway.parent) if gateway else config.fleet())
|
|
718
|
+
agents, rules = config.agents(), config.rules()
|
|
719
|
+
logins = config.raw.get("logins", {})
|
|
720
|
+
|
|
721
|
+
def user(name: str) -> str:
|
|
722
|
+
return str(logins.get(name, {}).get("user", name))
|
|
723
|
+
|
|
724
|
+
given_by = grants([*agents, *config.clients()], rules, fleet)
|
|
725
|
+
by_user = {user(name): given for name, given in given_by.items()}
|
|
726
|
+
allowed = {name: set(given) for name, given in by_user.items()}
|
|
727
|
+
for rule in rules:
|
|
728
|
+
agent, _, action = rule.calls.partition("__")
|
|
729
|
+
if action != "approve":
|
|
730
|
+
allowed[user(rule.name)].add((f"{AGENT_ID}{agent}", action, "invokeaction"))
|
|
731
|
+
serves = config.gateways(fleet)
|
|
732
|
+
if served.get("login"):
|
|
733
|
+
serves[served["login"]["user"]] = [td["id"] for td in fleet]
|
|
734
|
+
own = (config.raw.get("login") or {}).get("user")
|
|
735
|
+
if own:
|
|
736
|
+
serves[own] = [*serves.get(own, []), *(td["id"] for td in config.own())]
|
|
737
|
+
allowed.setdefault(own, set())
|
|
738
|
+
callers = list(dict.fromkeys([*allowed, *config.raw.get("callers", [])]))
|
|
739
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
740
|
+
acl = acl_file(
|
|
741
|
+
gateways=list(serves), callers=callers, prefix=config.prefix, grants=allowed, serves=serves
|
|
742
|
+
)
|
|
743
|
+
written = {
|
|
744
|
+
"grants.json": json.dumps(saved(by_user), indent=1),
|
|
745
|
+
"acl": acl,
|
|
746
|
+
}
|
|
747
|
+
for name, text in written.items():
|
|
748
|
+
(out / name).write_text(text if text.endswith("\n") else text + "\n")
|
|
749
|
+
return [out / name for name in written]
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def main(argv: list[str] | None = None) -> int:
|
|
753
|
+
parser = argparse.ArgumentParser(prog="thingagent")
|
|
754
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
755
|
+
commands.add_parser("check", help="check agents and rules against the fleet").add_argument(
|
|
756
|
+
"config", type=Path
|
|
757
|
+
)
|
|
758
|
+
commands.add_parser("serve", help="run the agents and their rules").add_argument(
|
|
759
|
+
"config", type=Path
|
|
760
|
+
)
|
|
761
|
+
chat = commands.add_parser("chat", help="run the agents with a chat in this terminal")
|
|
762
|
+
chat.add_argument("config", type=Path)
|
|
763
|
+
chat.add_argument("--as", dest="person", required=True, help="who you are in the chat")
|
|
764
|
+
do = commands.add_parser("do", help="run the agents until one has done one task")
|
|
765
|
+
do.add_argument("config", type=Path)
|
|
766
|
+
do.add_argument("agent", help="the agent that does the task")
|
|
767
|
+
do.add_argument("task", help="the task, in plain words")
|
|
768
|
+
do.add_argument("--as", dest="person", help="chat in this terminal as this person")
|
|
769
|
+
generate = commands.add_parser("generate", help="write the gateway grants and broker rules")
|
|
770
|
+
generate.add_argument("config", type=Path)
|
|
771
|
+
generate.add_argument("--out", type=Path, required=True, help="the directory to write to")
|
|
772
|
+
generate.add_argument("--gateway", type=Path, help="the fleet gateway's configuration")
|
|
773
|
+
ask = commands.add_parser("ask", help="run the agents until one has answered one prompt")
|
|
774
|
+
ask.add_argument("config", type=Path)
|
|
775
|
+
ask.add_argument("agent", help="the agent that answers")
|
|
776
|
+
ask.add_argument("prompt", help="the prompt, in plain words")
|
|
777
|
+
commands.add_parser("gateway", help="serve the fleet's devices on the bus").add_argument(
|
|
778
|
+
"config", type=Path
|
|
779
|
+
)
|
|
780
|
+
mcp = commands.add_parser("mcp", help="serve the fleet to an MCP client over stdio")
|
|
781
|
+
mcp.add_argument("config", type=Path)
|
|
782
|
+
mcp.add_argument("client", help="the client in the config that the MCP client calls as")
|
|
783
|
+
args = parser.parse_args(argv)
|
|
784
|
+
if args.command == "gateway":
|
|
785
|
+
asyncio.run(_serve_fleet(args.config))
|
|
786
|
+
return 0
|
|
787
|
+
config = Config(args.config)
|
|
788
|
+
if args.command == "do":
|
|
789
|
+
did = asyncio.run(_do(config, args.agent, args.task, args.person))
|
|
790
|
+
print(did.get("answer", ""))
|
|
791
|
+
for record in did.get("acted", []):
|
|
792
|
+
kinds = record.get("@type", [])
|
|
793
|
+
kind = " ".join(
|
|
794
|
+
k.partition(":")[2] for k in ([kinds] if isinstance(kinds, str) else kinds)
|
|
795
|
+
)
|
|
796
|
+
status = str(record.get("schema:actionStatus", "")).removeprefix("schema:")
|
|
797
|
+
print(f" {kind} {record.get('sosa:actsOnProperty', '')} {status}".rstrip())
|
|
798
|
+
return 0 if did else 1
|
|
799
|
+
if args.command == "ask":
|
|
800
|
+
print(asyncio.run(_ask(config, args.agent, args.prompt)))
|
|
801
|
+
return 0
|
|
802
|
+
if args.command == "generate":
|
|
803
|
+
for path in asyncio.run(_generate(config, args.out, args.gateway)):
|
|
804
|
+
print(f"wrote {path}")
|
|
805
|
+
return 0
|
|
806
|
+
if args.command == "mcp":
|
|
807
|
+
asyncio.run(_mcp(config, args.client))
|
|
808
|
+
return 0
|
|
809
|
+
if args.command == "check":
|
|
810
|
+
problems = _check(config, asyncio.run(config.fleet()))
|
|
811
|
+
for problem in problems:
|
|
812
|
+
print(problem)
|
|
813
|
+
return 1 if problems else 0
|
|
814
|
+
asyncio.run(_run(config, getattr(args, "person", None)))
|
|
815
|
+
return 0
|