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 ADDED
@@ -0,0 +1,26 @@
1
+ # Copyright 2026 The thingagent Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """thingagent: LLM agents served as WoT Things, acting only through thingctx's gate."""
4
+
5
+ from thingagent.agent import Agent, Question, Step
6
+ from thingagent.channel import ChatChannel
7
+ from thingagent.check import check
8
+ from thingagent.decide import ChatDecider, Decider, DecisionsDecider, checked
9
+ from thingagent.grants import grants
10
+ from thingagent.harness import Harness
11
+ from thingagent.rules import Rule
12
+
13
+ __all__ = [
14
+ "Agent",
15
+ "ChatChannel",
16
+ "ChatDecider",
17
+ "Decider",
18
+ "DecisionsDecider",
19
+ "Harness",
20
+ "Question",
21
+ "Rule",
22
+ "Step",
23
+ "check",
24
+ "checked",
25
+ "grants",
26
+ ]
thingagent/agent.py ADDED
@@ -0,0 +1,296 @@
1
+ # Copyright 2026 The thingagent Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """An agent's definition, and the Thing Description it is served under.
4
+
5
+ The definition holds the behavior: the model, instructions, questions, the table
6
+ from each answer to its steps, the properties it reads, the actions it may take on
7
+ a task, the limits on their arguments, and which of them a person must approve.
8
+ The TD holds only the interface (``decide``, ``ask``, ``approve``, ``do`` for an
9
+ agent that may act, and the ``decided`` and ``did`` events) plus every value a
10
+ decision may choose, so a reader sees what the agent can decide without seeing how.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from thingctx.authz import limited
21
+
22
+ QUESTION_TYPES = ("predicate", "choice", "score")
23
+ # Every agent's Thing id starts with this, whichever harness serves it.
24
+ AGENT_ID = "urn:thingagent:agent:"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Step:
29
+ """One action a decision takes: a fleet action tool, its arguments, and the
30
+ question a person must approve first, if any."""
31
+
32
+ invoke: str
33
+ args: dict[str, Any] = field(default_factory=dict)
34
+ approve: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Question:
39
+ """A question shaped like OpenAI's Decisions API: a ``predicate`` answered
40
+ with a probability, a ``choice`` among ``values``, or a ``score`` among
41
+ ordered ``values``."""
42
+
43
+ type: str
44
+ name: str
45
+ instructions: str
46
+ values: tuple[str, ...] = ()
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Approval:
51
+ """How a person approves a step: how long to wait, and the steps that show a
52
+ question is waiting (``signal``) and answered (``clear``)."""
53
+
54
+ timeout: float | None = None
55
+ signal: tuple[Step, ...] = ()
56
+ clear: tuple[Step, ...] = ()
57
+
58
+
59
+ def _steps(raw: list[dict[str, Any]]) -> tuple[Step, ...]:
60
+ return tuple(Step(s["invoke"], s.get("args", {}), s.get("approve")) for s in raw)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Agent:
65
+ """One agent. ``act`` maps a question name, then an answer value, to the steps
66
+ that answer takes; an answer with no entry takes none. ``may`` names the fleet
67
+ actions the agent may take on a task, with arguments it chooses. ``limits``
68
+ maps any action it may take or its steps take to a JSON Schema its arguments
69
+ must satisfy. ``approve`` maps any action it may take to the question its
70
+ person must say yes to first. ``options`` configure the model library or
71
+ Decisions endpoint with every call."""
72
+
73
+ name: str
74
+ title: str
75
+ instructions: str
76
+ model: str = ""
77
+ questions: tuple[Question, ...] = ()
78
+ act: dict[str, dict[str, tuple[Step, ...]]] = field(default_factory=dict)
79
+ reads: tuple[str, ...] = ()
80
+ image: str | None = None
81
+ person: str | None = None
82
+ may: tuple[str, ...] = ()
83
+ limits: dict[str, dict[str, Any]] = field(default_factory=dict)
84
+ approve: dict[str, str] = field(default_factory=dict)
85
+ approval: Approval = field(default_factory=Approval)
86
+ rounds: int = 12
87
+ options: dict[str, Any] = field(default_factory=dict)
88
+
89
+ @classmethod
90
+ def from_dict(cls, raw: dict[str, Any]) -> Agent:
91
+ """Build an agent from its definition. Raises ``ValueError`` for a question
92
+ of an unknown type, a step under an answer the question cannot give, a limit
93
+ or an approval for an action the agent may not take, or a limit with a
94
+ numeric bound and no ``type``, which a value that is no number would pass."""
95
+ questions = tuple(
96
+ Question(q["type"], q["name"], q.get("instructions", ""), tuple(q.get("values", ())))
97
+ for q in raw.get("questions", ())
98
+ )
99
+ for q in questions:
100
+ if q.type not in QUESTION_TYPES:
101
+ raise ValueError(f"question {q.name!r} has unknown type {q.type!r}")
102
+ allowed = {q.name: set(q.values) for q in questions}
103
+ act: dict[str, dict[str, tuple[Step, ...]]] = {}
104
+ for qname, by_value in raw.get("act", {}).items():
105
+ for value in by_value:
106
+ if value not in allowed.get(qname, set()):
107
+ raise ValueError(f"act names {qname}={value!r}, which no question can answer")
108
+ act[qname] = {value: _steps(steps) for value, steps in by_value.items()}
109
+ may = tuple(raw.get("may", ()))
110
+ approve = dict(raw.get("approve", {}))
111
+ for action in approve:
112
+ if action not in may:
113
+ raise ValueError(f"approve names {action}, which the agent may not take")
114
+ approval = raw.get("approval", {})
115
+ agent = cls(
116
+ name=raw["name"],
117
+ title=raw.get("title", raw["name"]),
118
+ instructions=raw.get("instructions", ""),
119
+ model=raw.get("model", ""),
120
+ questions=questions,
121
+ act=act,
122
+ reads=tuple(raw.get("reads", ())),
123
+ image=raw.get("image"),
124
+ person=raw.get("person"),
125
+ may=may,
126
+ limits=dict(raw.get("limits", {})),
127
+ approve=approve,
128
+ approval=Approval(
129
+ approval.get("timeout"),
130
+ _steps(approval.get("signal", [])),
131
+ _steps(approval.get("clear", [])),
132
+ ),
133
+ rounds=raw.get("rounds", 12),
134
+ options=dict(raw.get("options", {})),
135
+ )
136
+ taken = {*agent.may, *(step.invoke for step in agent.steps())}
137
+ for action in agent.limits:
138
+ if action not in taken:
139
+ raise ValueError(f"limits names {action}, which the agent may not take")
140
+ try:
141
+ limited(agent.name, action, "invokeaction", agent.limits[action])
142
+ except ValueError as exc:
143
+ raise ValueError(f"agent {agent.name}, limits {action}: {exc}") from None
144
+ return agent
145
+
146
+ @classmethod
147
+ def load(cls, path: str | Path) -> Agent:
148
+ return cls.from_dict(json.loads(Path(path).read_text()))
149
+
150
+ @property
151
+ def decides_only(self) -> bool:
152
+ """True for a Decisions model, which answers only decide."""
153
+ return self.model.startswith("decisions/")
154
+
155
+ def steps(self) -> tuple[Step, ...]:
156
+ """Every step the act table and approvals can run, each with fixed
157
+ arguments."""
158
+ table = (s for by_value in self.act.values() for steps in by_value.values() for s in steps)
159
+ return (*table, *self.approval.signal, *self.approval.clear)
160
+
161
+ def _decision(self) -> dict[str, Any]:
162
+ """The schema of what ``decide`` returns and ``decided`` reports: every
163
+ answer it may give, each in its Decisions shape, or a refusal and its reason,
164
+ and the usage and cost the model reports."""
165
+ shapes: list[dict[str, Any]] = []
166
+ for q in self.questions:
167
+ value: dict[str, Any] = (
168
+ {"type": "number", "minimum": 0, "maximum": 1}
169
+ if q.type == "predicate"
170
+ else {"enum": list(q.values)}
171
+ )
172
+ field = "probability" if q.type == "predicate" else q.type
173
+ properties = {"type": {"const": q.type}, "name": {"const": q.name}, field: value}
174
+ if q.type != "predicate":
175
+ properties["confidence"] = {"type": "number"}
176
+ shapes.append(
177
+ {"type": "object", "properties": properties, "required": ["type", "name", field]}
178
+ )
179
+ answers: dict[str, Any] = {"type": "array", "maxItems": 0}
180
+ if self.questions:
181
+ refusal = {
182
+ "type": "object",
183
+ "properties": {
184
+ "type": {"const": "refusal"},
185
+ "name": {"enum": [q.name for q in self.questions]},
186
+ "reason": {"enum": ["model_refused", "not_a_choice", "request_failed"]},
187
+ },
188
+ "required": ["type", "name"],
189
+ }
190
+ answers = {"type": "array", "items": {"oneOf": [*shapes, refusal]}}
191
+ return {
192
+ "type": "object",
193
+ "properties": {
194
+ "agent": {"const": self.name},
195
+ "correlation": {"type": "string"},
196
+ "trigger": {"type": "object"},
197
+ "answers": answers,
198
+ "acted": {"type": "array", "items": {"type": "object"}},
199
+ "at": {"type": "string", "format": "date-time"},
200
+ "usage": {
201
+ "type": "object",
202
+ "properties": {
203
+ "input_tokens": {"type": "integer"},
204
+ "output_tokens": {"type": "integer"},
205
+ },
206
+ },
207
+ "cost": {"type": "number"},
208
+ },
209
+ "required": ["agent", "correlation", "trigger", "answers", "acted", "at"],
210
+ }
211
+
212
+ def td(self) -> dict[str, Any]:
213
+ """The agent's Thing Description: its interface and its allowed answers."""
214
+ decision = self._decision()
215
+ td: dict[str, Any] = {
216
+ "@context": "https://www.w3.org/2022/wot/td/v1.1",
217
+ "id": f"{AGENT_ID}{self.name}",
218
+ "title": self.title,
219
+ "securityDefinitions": {"nosec_sc": {"scheme": "nosec"}},
220
+ "security": ["nosec_sc"],
221
+ "actions": {
222
+ "decide": {
223
+ "description": (
224
+ "Answer the agent's questions about a trigger and take the steps "
225
+ "the answers call for. A trigger whose correlation was already "
226
+ "decided gets that decision again and runs nothing."
227
+ ),
228
+ "input": {"type": "object"},
229
+ "output": decision,
230
+ "forms": [{"href": "local://decide"}],
231
+ },
232
+ "ask": {
233
+ "description": "Read Things and answer. Never acts.",
234
+ "synchronous": True,
235
+ "input": {
236
+ "type": "object",
237
+ "properties": {"prompt": {"type": "string"}},
238
+ "required": ["prompt"],
239
+ },
240
+ "output": {"type": "object", "properties": {"answer": {"type": "string"}}},
241
+ "forms": [{"href": "local://ask"}],
242
+ },
243
+ "approve": {
244
+ "description": (
245
+ "A person's answer to the question the agent asked before taking "
246
+ "action, a tool name. It answers no question about another action."
247
+ ),
248
+ "input": {
249
+ "type": "object",
250
+ "properties": {
251
+ "person": {"type": "string"},
252
+ "yes": {"type": "boolean"},
253
+ "action": {"type": "string"},
254
+ },
255
+ "required": ["person", "yes", "action"],
256
+ },
257
+ "output": {"type": "object"},
258
+ "forms": [{"href": "local://approve"}],
259
+ },
260
+ },
261
+ "events": {"decided": {"data": decision, "forms": [{"href": "local://decided"}]}},
262
+ }
263
+ if self.may:
264
+ did = {
265
+ "type": "object",
266
+ "properties": {
267
+ "agent": {"const": self.name},
268
+ "correlation": {"type": "string"},
269
+ "task": {"type": "string"},
270
+ "answer": {"type": "string"},
271
+ "acted": {"type": "array", "items": {"type": "object"}},
272
+ "at": {"type": "string", "format": "date-time"},
273
+ },
274
+ "required": ["agent", "correlation", "task", "answer", "acted", "at"],
275
+ }
276
+ td["actions"]["do"] = {
277
+ "description": (
278
+ "Carry out a task with the actions the agent may take, each checked "
279
+ "where it lands, some after a person approves. A repeated correlation "
280
+ "gets the first result again and runs nothing."
281
+ ),
282
+ "synchronous": True,
283
+ "input": {
284
+ "type": "object",
285
+ "properties": {
286
+ "task": {"type": "string"},
287
+ "correlation": {"type": "string"},
288
+ "trigger": {"type": "object"},
289
+ },
290
+ "required": ["task"],
291
+ },
292
+ "output": did,
293
+ "forms": [{"href": "local://do"}],
294
+ }
295
+ td["events"]["did"] = {"data": did, "forms": [{"href": "local://did"}]}
296
+ return td
thingagent/channel.py ADDED
@@ -0,0 +1,121 @@
1
+ # Copyright 2026 The thingagent Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """A chat channel, served as a Thing.
4
+
5
+ A chat service adapter calls ``receive`` for each message a person writes and
6
+ delivers each ``message`` event that ``send`` emits. When a person has a question
7
+ pending, their next message is read as the answer, not as a new request.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from collections.abc import AsyncIterator
15
+ from typing import Any
16
+
17
+ from thingctx.bindings import LocalBinding
18
+
19
+ _log = logging.getLogger("thingagent")
20
+ YES = frozenset({"yes", "y", "ok", "approve", "approved"})
21
+
22
+
23
+ class ChatChannel:
24
+ def __init__(self, name: str = "chat") -> None:
25
+ self.name = name
26
+ self.outbox: list[dict[str, str]] = []
27
+ self.binding = LocalBinding({"send": self.send, "receive": self.receive})
28
+ self._waiting: dict[str, asyncio.Future[str]] = {}
29
+ # The action each open question asks about, by person.
30
+ self._about: dict[str, str | None] = {}
31
+ # One open question per person, so a reply cannot answer the wrong one.
32
+ self._turns: dict[str, asyncio.Lock] = {}
33
+ self._inbox: asyncio.Queue[dict[str, str]] = asyncio.Queue()
34
+
35
+ def td(self) -> dict[str, Any]:
36
+ text = {"person": {"type": "string"}, "text": {"type": "string"}}
37
+ message = {"type": "object", "properties": text, "required": ["person", "text"]}
38
+ done = {"type": "object"}
39
+ return {
40
+ "@context": "https://www.w3.org/2022/wot/td/v1.1",
41
+ "id": f"urn:thingagent:channel:{self.name}",
42
+ "title": f"Chat channel {self.name}",
43
+ "securityDefinitions": {"nosec_sc": {"scheme": "nosec"}},
44
+ "security": ["nosec_sc"],
45
+ "actions": {
46
+ "send": {"input": message, "output": done, "forms": [{"href": "local://send"}]},
47
+ "receive": {
48
+ "input": message,
49
+ "output": done,
50
+ "forms": [{"href": "local://receive"}],
51
+ },
52
+ },
53
+ "events": {
54
+ "message": {
55
+ "description": "A message for a person, for an adapter to deliver.",
56
+ "data": message,
57
+ "forms": [{"href": "local://message"}],
58
+ }
59
+ },
60
+ }
61
+
62
+ async def send(self, person: str, text: str) -> dict[str, Any]:
63
+ message = {"person": person, "text": text}
64
+ self.outbox.append(message)
65
+ self.binding.emit("message", message)
66
+ return {"sent": True}
67
+
68
+ async def receive(self, person: str, text: str) -> dict[str, Any]:
69
+ waiting = self._waiting.pop(person, None)
70
+ if waiting is not None and not waiting.done():
71
+ waiting.set_result(text)
72
+ return {"answered": True}
73
+ self._inbox.put_nowait({"person": person, "text": text})
74
+ return {"received": True}
75
+
76
+ async def answer(self, person: str, yes: bool, action: str) -> dict[str, Any]:
77
+ """Answer the question ``person`` has open, only when it asks about
78
+ ``action``. Unlike ``receive``, it drops an answer with no open question, or
79
+ one about another action, instead of reading it as a request; the question
80
+ stays open."""
81
+ waiting = self._waiting.get(person)
82
+ if waiting is None or waiting.done():
83
+ return {"answered": False}
84
+ if self._about.get(person) != action:
85
+ _log.warning(
86
+ "thingagent: dropped an answer for %s about %s, not its question", person, action
87
+ )
88
+ return {"answered": False, "refused": f"{person}'s open question is not about {action}"}
89
+ del self._waiting[person]
90
+ waiting.set_result("yes" if yes else "no")
91
+ return {"answered": True}
92
+
93
+ async def messages(self) -> AsyncIterator[dict[str, str]]:
94
+ """Every message that is not an answer, for the harness that routes them."""
95
+ while True:
96
+ yield await self._inbox.get()
97
+
98
+ async def confirm(
99
+ self, person: str, question: str, timeout: float, action: str | None = None
100
+ ) -> bool:
101
+ """Put ``question`` to ``person`` and wait for the reply. Only a yes within
102
+ ``timeout`` seconds confirms; silence is a no. Questions to one person
103
+ queue: a later one is asked once the earlier one is answered or times out,
104
+ and then gets its own ``timeout``. Only ``answer`` naming ``action``, or the
105
+ person's own reply, answers it."""
106
+ async with self._turns.setdefault(person, asyncio.Lock()):
107
+ future: asyncio.Future[str] = asyncio.get_running_loop().create_future()
108
+ self._waiting[person] = future
109
+ self._about[person] = action
110
+ await self.send(person, question)
111
+ try:
112
+ reply = await asyncio.wait_for(future, timeout)
113
+ except asyncio.TimeoutError:
114
+ _log.warning(
115
+ "thingagent: %s did not answer in %g s, so the answer is no", person, timeout
116
+ )
117
+ return False
118
+ finally:
119
+ if self._waiting.get(person) is future:
120
+ del self._waiting[person]
121
+ return reply.strip().lower() in YES
thingagent/check.py ADDED
@@ -0,0 +1,231 @@
1
+ # Copyright 2026 The thingagent Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Check agents and rules against the fleet's TDs before anything runs."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from collections import Counter
8
+ from collections.abc import Collection, Mapping
9
+ from typing import Any
10
+
11
+ from thingctx.thing import thing_slug
12
+
13
+ from thingagent.agent import Agent
14
+ from thingagent.rules import Rule, broker_address, loopback
15
+
16
+
17
+ def _index(tds: list[dict[str, Any]], kind: str) -> dict[str, dict[str, Any]]:
18
+ return {
19
+ f"{thing_slug(td['id'])}__{name}": affordance
20
+ for td in tds
21
+ for name, affordance in td.get(kind, {}).items()
22
+ }
23
+
24
+
25
+ def _only_mqtt(affordance: dict[str, Any]) -> bool:
26
+ # thingctx sends an MQTT action with no declared output without waiting for a
27
+ # reply, unless the call carries the caller's token.
28
+ forms = affordance.get("forms", [])
29
+ return bool(forms) and all(f.get("href", "").startswith("mqtt") for f in forms)
30
+
31
+
32
+ def _carries_token(td: dict[str, Any]) -> bool:
33
+ """True when thingctx sends the caller's token with each message to ``td``'s
34
+ Thing, so each call waits for an answer: an active scheme is a header bearer
35
+ scheme that names its parameter."""
36
+ defs = td.get("securityDefinitions", {})
37
+ active = td.get("security", [])
38
+ schemes = [defs.get(name, {}) for name in ([active] if isinstance(active, str) else active)]
39
+ return any(
40
+ s.get("scheme") == "bearer" and s.get("in", "header") == "header" and bool(s.get("name"))
41
+ for s in schemes
42
+ )
43
+
44
+
45
+ def _observable(prop: dict[str, Any] | None) -> bool:
46
+ return prop is not None and any(
47
+ "observeproperty" in f.get("op", []) for f in prop.get("forms", [])
48
+ )
49
+
50
+
51
+ def _calls(rule: Rule, agents: dict[str, Agent]) -> list[str]:
52
+ """What is wrong with the agent action ``rule`` calls and the input it gives."""
53
+ name, _, action = rule.calls.partition("__")
54
+ agent = agents.get(name)
55
+ if agent is None or action not in ("decide", "do", "approve"):
56
+ return [
57
+ f"rule {rule.name} calls {rule.calls}, which is not an agent's decide, do or approve"
58
+ ]
59
+ if action == "do" and not agent.may:
60
+ return [f"rule {rule.name} calls {rule.calls}, but {name} may take no action"]
61
+ if action == "do" and not isinstance(rule.args.get("task"), str):
62
+ return [f"rule {rule.name} calls {rule.calls} with no task in its args"]
63
+ if action == "approve" and not (
64
+ isinstance(rule.args.get("person"), str)
65
+ and isinstance(rule.args.get("yes"), bool)
66
+ and isinstance(rule.args.get("action"), str)
67
+ ):
68
+ return [
69
+ f"rule {rule.name} calls {rule.calls} without a person, a yes and an action in its args"
70
+ ]
71
+ return []
72
+
73
+
74
+ def plain_bus_risk(broker: str, *, tls: bool, insecure_bus: bool) -> str | None:
75
+ """Why ``broker`` may not be used, or None: its host is not this machine and the
76
+ link has no TLS (neither ``mqtts://`` nor ``tls``), unless ``insecure_bus``."""
77
+ try:
78
+ secure = tls or broker_address(broker)[2]
79
+ except ValueError as exc:
80
+ return str(exc)
81
+ if secure or insecure_bus or loopback(broker):
82
+ return None
83
+ return (
84
+ f"the broker {broker} is not on this machine and the link has no TLS, so passwords "
85
+ 'and tokens cross the network in the clear; use mqtts:// or tls, or set "insecure_bus": '
86
+ "true to accept that risk"
87
+ )
88
+
89
+
90
+ def open_bus_risks(
91
+ agents: list[Agent],
92
+ *,
93
+ broker_binds_identity: bool,
94
+ logins: Collection[str],
95
+ guard: bool,
96
+ policy: bool,
97
+ ) -> list[str]:
98
+ """Each agent that can act on a bus where a token read off it can be replayed
99
+ (the broker is not said to bind each login to its identity, or the agent has no
100
+ login of its own) or where any login can call it (the agents' own gateway has
101
+ no ``guard`` or no ``policy``)."""
102
+ why = (
103
+ "it has no login"
104
+ if broker_binds_identity
105
+ else "the broker is not set to bind each login to its identity"
106
+ )
107
+ lacks = " and ".join(f"no {n}" for n, has in (("guard", guard), ("policy", policy)) if not has)
108
+ risks: list[str] = []
109
+ for agent in (a for a in agents if a.steps() or a.may):
110
+ if not (broker_binds_identity and agent.name in logins):
111
+ risks.append(
112
+ f"{agent.name} can act on the fleet, but {why}, so anyone on the bus can "
113
+ "replay a token read there or publish its events; set broker_binds_identity "
114
+ 'to true and give it a login, or set "open_bus": true to accept that risk'
115
+ )
116
+ if lacks:
117
+ risks.append(
118
+ f"{agent.name} can act on the fleet, but the agents' gateway has {lacks}, so "
119
+ 'any login on the bus may call it; set guard and policy, or set "open_bus": '
120
+ "true to accept that risk"
121
+ )
122
+ return risks
123
+
124
+
125
+ def check(
126
+ agents: list[Agent],
127
+ rules: list[Rule],
128
+ fleet: list[dict[str, Any]],
129
+ *,
130
+ tokens: Collection[str] = (),
131
+ allow: Mapping[str, Collection[str]] | None = None,
132
+ broker: str | None = None,
133
+ broker_binds_identity: bool = False,
134
+ logins: Collection[str] = (),
135
+ guard: bool = False,
136
+ policy: bool = False,
137
+ open_bus: bool = False,
138
+ tls: bool = False,
139
+ insecure_bus: bool = False,
140
+ clients: Collection[Agent] = (),
141
+ ) -> list[str]:
142
+ """Every problem found, once each, as a sentence; an empty list means none.
143
+ ``tokens`` names the agents that call with a token. ``allow`` maps a person to
144
+ the agents they may chat with. With ``broker``, an agent that can act needs
145
+ ``broker_binds_identity``, a login in ``logins``, and a ``guard`` and a
146
+ ``policy`` on the agents' gateway, unless ``open_bus``, and a broker that is not
147
+ on this machine needs ``tls`` or an ``mqtts://`` address, unless
148
+ ``insecure_bus`` (see :func:`plain_bus_risk`). A rule calls an agent
149
+ this harness runs. ``clients`` call from outside, so each only reads and takes
150
+ actions; one with a model, an act table or questions is refused."""
151
+ properties, actions, events = (_index(fleet, k) for k in ("properties", "actions", "events"))
152
+ owners = {
153
+ f"{thing_slug(td['id'])}__{name}": td for td in fleet for name in td.get("actions", {})
154
+ }
155
+ problems: list[str] = []
156
+ # A name is an identity: the token's sub, the broker login and the grants' key.
157
+ names = Counter([*(a.name for a in (*agents, *clients)), *(r.name for r in rules)])
158
+ problems += [
159
+ f"{name} names more than one agent, client or rule; each needs a name of its own"
160
+ for name, count in names.items()
161
+ if count > 1
162
+ ]
163
+ plain = broker and plain_bus_risk(broker, tls=tls, insecure_bus=insecure_bus)
164
+ if plain:
165
+ problems.append(plain)
166
+ if broker and not open_bus:
167
+ problems += open_bus_risks(
168
+ agents,
169
+ broker_binds_identity=broker_binds_identity,
170
+ logins=logins,
171
+ guard=guard,
172
+ policy=policy,
173
+ )
174
+ problems += [
175
+ f"client {client.name} has {', '.join(has)}, but a client only reads and takes actions"
176
+ for client in clients
177
+ if (has := [k for k in ("model", "act", "questions") if getattr(client, k)])
178
+ ]
179
+ for agent in (*agents, *clients):
180
+ if agent.decides_only:
181
+ # A decisions model answers only decide; every other call needs a chat model.
182
+ only = f"{agent.name} has a decisions model, which can only decide,"
183
+ if agent.may:
184
+ problems.append(f"{only} but it may take actions on a task")
185
+ if not agent.questions:
186
+ problems.append(f"{only} but it has no questions")
187
+ problems += [
188
+ f"{only} but {person} may chat with it"
189
+ for person, names in (allow or {}).items()
190
+ if agent.name in names
191
+ ]
192
+ problems += [
193
+ f"{agent.name} reads {name}, which no fleet TD has"
194
+ for name in (*agent.reads, *([agent.image] if agent.image else []))
195
+ if name not in properties
196
+ ]
197
+ for invoke in (*(step.invoke for step in agent.steps()), *agent.may):
198
+ action = actions.get(invoke)
199
+ if action is None:
200
+ problems.append(f"{agent.name} invokes {invoke}, which no fleet TD has")
201
+ elif action is not None and (
202
+ "output" not in action
203
+ and _only_mqtt(action)
204
+ and not (agent.name in tokens and _carries_token(owners[invoke]))
205
+ ):
206
+ problems.append(
207
+ f"{agent.name} invokes {invoke}, which declares no output, "
208
+ "so over MQTT nothing confirms it ran or was refused"
209
+ )
210
+ by_name = {agent.name: agent for agent in agents}
211
+ for rule in rules:
212
+ problems += _calls(rule, by_name)
213
+ event = rule.event
214
+ observed = event not in events and _observable(properties.get(event))
215
+ data = properties[event] if observed else events.get(event, {}).get("data")
216
+ fields = data.get("properties", {}) if isinstance(data, dict) else {}
217
+ needed = [k.split(".")[0] for k in rule.when]
218
+ if event not in events and not observed:
219
+ problems.append(
220
+ f"rule {rule.name} reads {event}, which no fleet TD has as an event or "
221
+ "an observable property"
222
+ )
223
+ elif not isinstance(data, dict) or data.get("type") != "object":
224
+ problems.append(f"rule {rule.name} reads {event}, whose data is not an object")
225
+ else:
226
+ problems += [
227
+ f"rule {rule.name} reads {name} from {event}, which its data lacks"
228
+ for name in needed
229
+ if name not in fields
230
+ ]
231
+ return list(dict.fromkeys(problems))