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