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/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