thingctx 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.
- thingctx/__init__.py +73 -0
- thingctx/client.py +79 -0
- thingctx/contrib/__init__.py +8 -0
- thingctx/contrib/llm.py +215 -0
- thingctx/data/td-schema-1.1.json +1493 -0
- thingctx/extensions/__init__.py +8 -0
- thingctx/extensions/prompts.py +101 -0
- thingctx/integrations/__init__.py +6 -0
- thingctx/integrations/mcp.py +214 -0
- thingctx/invokers.py +451 -0
- thingctx/registry.py +101 -0
- thingctx/runtime.py +183 -0
- thingctx/thing.py +353 -0
- thingctx/validate.py +61 -0
- thingctx-0.1.1.dist-info/METADATA +216 -0
- thingctx-0.1.1.dist-info/RECORD +21 -0
- thingctx-0.1.1.dist-info/WHEEL +5 -0
- thingctx-0.1.1.dist-info/entry_points.txt +2 -0
- thingctx-0.1.1.dist-info/licenses/LICENSE +204 -0
- thingctx-0.1.1.dist-info/licenses/NOTICE +5 -0
- thingctx-0.1.1.dist-info/top_level.txt +1 -0
thingctx/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Consume a WoT Thing Description and drive the Thing over any transport.
|
|
2
|
+
|
|
3
|
+
Parse a TD, present its actions as tools, and invoke each over the
|
|
4
|
+
transport its form names. Depends on stdlib; litellm, httpx, paho-mqtt
|
|
5
|
+
are optional extras.
|
|
6
|
+
|
|
7
|
+
import thingctx
|
|
8
|
+
host = await thingctx.from_url("http://device.local/.well-known/wot")
|
|
9
|
+
print(await host.chat("turn on the pump and report its status"))
|
|
10
|
+
|
|
11
|
+
host = thingctx.from_file("pump.td.json")
|
|
12
|
+
host = thingctx.from_td(td_dict)
|
|
13
|
+
|
|
14
|
+
For the pure client without an LLM, build a ThingClient directly.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# ThingClient: TD -> tools + invoke/read/write/observe/subscribe. No LLM.
|
|
18
|
+
from thingctx.client import from_file, from_td, from_url
|
|
19
|
+
|
|
20
|
+
# LLMHost: optional tool-calling loop, in thingctx.contrib.
|
|
21
|
+
from thingctx.contrib.llm import LLMHost
|
|
22
|
+
from thingctx.invokers import (
|
|
23
|
+
HttpInvoker,
|
|
24
|
+
Invoker,
|
|
25
|
+
LocalInvoker,
|
|
26
|
+
MqttInvoker,
|
|
27
|
+
)
|
|
28
|
+
from thingctx.registry import (
|
|
29
|
+
FileRegistry,
|
|
30
|
+
Registry,
|
|
31
|
+
TDDRegistry,
|
|
32
|
+
from_arg,
|
|
33
|
+
from_args,
|
|
34
|
+
)
|
|
35
|
+
from thingctx.runtime import ThingClient
|
|
36
|
+
from thingctx.thing import (
|
|
37
|
+
WoTAction,
|
|
38
|
+
WoTEvent,
|
|
39
|
+
WoTProperty,
|
|
40
|
+
WoTSecurityScheme,
|
|
41
|
+
WoTThing,
|
|
42
|
+
actions_to_tools,
|
|
43
|
+
parse_thing,
|
|
44
|
+
)
|
|
45
|
+
from thingctx.validate import TDValidationError, validate_td
|
|
46
|
+
|
|
47
|
+
__version__ = "0.1.1"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"from_url",
|
|
51
|
+
"from_file",
|
|
52
|
+
"from_td",
|
|
53
|
+
"ThingClient",
|
|
54
|
+
"LLMHost",
|
|
55
|
+
"Registry",
|
|
56
|
+
"FileRegistry",
|
|
57
|
+
"TDDRegistry",
|
|
58
|
+
"from_arg",
|
|
59
|
+
"from_args",
|
|
60
|
+
"WoTThing",
|
|
61
|
+
"WoTAction",
|
|
62
|
+
"WoTProperty",
|
|
63
|
+
"WoTEvent",
|
|
64
|
+
"WoTSecurityScheme",
|
|
65
|
+
"validate_td",
|
|
66
|
+
"TDValidationError",
|
|
67
|
+
"parse_thing",
|
|
68
|
+
"actions_to_tools",
|
|
69
|
+
"Invoker",
|
|
70
|
+
"HttpInvoker",
|
|
71
|
+
"MqttInvoker",
|
|
72
|
+
"LocalInvoker",
|
|
73
|
+
]
|
thingctx/client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Consume a TD and return a ready LLMHost.
|
|
2
|
+
|
|
3
|
+
host = await thingctx.from_url("http://device.local/.well-known/wot")
|
|
4
|
+
host = thingctx.from_file("pump.td.json")
|
|
5
|
+
host = thingctx.from_td(td_dict)
|
|
6
|
+
|
|
7
|
+
Each builds a ThingClient and wraps it in an LLMHost. For the pure client,
|
|
8
|
+
build a ThingClient directly or read host.client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from thingctx.contrib.llm import LLMHost
|
|
18
|
+
from thingctx.invokers import HttpInvoker, Invoker, LocalInvoker
|
|
19
|
+
from thingctx.runtime import ThingClient
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _default_invokers() -> list[Invoker]:
|
|
23
|
+
# HTTP covers http(s) forms; Local covers local:// forms. Add MQTT
|
|
24
|
+
# etc. explicitly via the ``invokers=`` argument when a TD uses them.
|
|
25
|
+
return [HttpInvoker(), LocalInvoker()]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def from_td(
|
|
29
|
+
td: dict[str, Any] | list[dict[str, Any]],
|
|
30
|
+
*,
|
|
31
|
+
model: str = "anthropic/claude-sonnet-4-6",
|
|
32
|
+
invokers: list[Invoker] | None = None,
|
|
33
|
+
validate: bool = False,
|
|
34
|
+
**host_kwargs: Any,
|
|
35
|
+
) -> LLMHost:
|
|
36
|
+
"""From one or more TD dicts. Defaults to HTTP + local invokers;
|
|
37
|
+
pass ``invokers=`` for MQTT/CoAP/custom transports a TD uses.
|
|
38
|
+
``validate=True`` checks each TD against the W3C TD 1.1 schema."""
|
|
39
|
+
tds = td if isinstance(td, list) else [td]
|
|
40
|
+
client = ThingClient(
|
|
41
|
+
tds=tds,
|
|
42
|
+
invokers=invokers if invokers is not None else _default_invokers(),
|
|
43
|
+
validate=validate,
|
|
44
|
+
)
|
|
45
|
+
return LLMHost(client, model=model, **host_kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def from_file(
|
|
49
|
+
path: str | Path,
|
|
50
|
+
*,
|
|
51
|
+
model: str = "anthropic/claude-sonnet-4-6",
|
|
52
|
+
invokers: list[Invoker] | None = None,
|
|
53
|
+
**host_kwargs: Any,
|
|
54
|
+
) -> LLMHost:
|
|
55
|
+
"""From a ``.td.json`` file (one TD or a list of TDs)."""
|
|
56
|
+
data = json.loads(Path(path).read_text())
|
|
57
|
+
return from_td(data, model=model, invokers=invokers, **host_kwargs)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def from_url(
|
|
61
|
+
url: str,
|
|
62
|
+
*,
|
|
63
|
+
model: str = "anthropic/claude-sonnet-4-6",
|
|
64
|
+
invokers: list[Invoker] | None = None,
|
|
65
|
+
**host_kwargs: Any,
|
|
66
|
+
) -> LLMHost:
|
|
67
|
+
"""Fetch a live Thing's TD from ``url`` and return a ready host.
|
|
68
|
+
|
|
69
|
+
``url`` points at the Thing Description document (e.g.
|
|
70
|
+
``http://device.local/.well-known/wot`` or a TD-Directory entry).
|
|
71
|
+
The device side is WoT's, thingctx just consumes the document.
|
|
72
|
+
"""
|
|
73
|
+
import httpx
|
|
74
|
+
|
|
75
|
+
async with httpx.AsyncClient() as http:
|
|
76
|
+
resp = await http.get(url)
|
|
77
|
+
resp.raise_for_status()
|
|
78
|
+
td = resp.json()
|
|
79
|
+
return from_td(td, model=model, invokers=invokers, **host_kwargs)
|
thingctx/contrib/llm.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""LLMHost: a tool-calling loop over a ThingClient. litellm is imported
|
|
2
|
+
lazily here only, so the pure ThingClient has no LLM dependency.
|
|
3
|
+
|
|
4
|
+
client = ThingClient(tds=[td], invokers=[HttpInvoker()])
|
|
5
|
+
host = LLMHost(client, model="anthropic/claude-sonnet-4-6")
|
|
6
|
+
print(await host.chat("read temp-1 and report it"))
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from thingctx.runtime import ThingClient, to_text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _summary_from_memo(memo: dict) -> str:
|
|
19
|
+
"""Fallback answer from the gathered tool results."""
|
|
20
|
+
if not memo:
|
|
21
|
+
return "(no answer)"
|
|
22
|
+
parts = [f"{name}: {result}" for (name, _args), result in memo.items()]
|
|
23
|
+
return "Completed: " + "; ".join(parts)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get(obj: Any, key: str) -> Any:
|
|
27
|
+
"""Read key from a dict or an object (litellm returns objects)."""
|
|
28
|
+
if obj is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(obj, dict):
|
|
31
|
+
return obj.get(key)
|
|
32
|
+
return getattr(obj, key, None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# (messages, tools) -> an assistant message dict (OpenAI shape).
|
|
36
|
+
ChatFn = Callable[
|
|
37
|
+
[list[dict[str, Any]], list[dict[str, Any]]],
|
|
38
|
+
Awaitable[dict[str, Any]],
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LLMHost:
|
|
43
|
+
"""Run an LLM tool-calling loop against a ThingClient."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
client: ThingClient,
|
|
48
|
+
*,
|
|
49
|
+
model: str = "anthropic/claude-sonnet-4-6",
|
|
50
|
+
system: str | None = None,
|
|
51
|
+
max_rounds: int = 8,
|
|
52
|
+
chat_fn: ChatFn | None = None,
|
|
53
|
+
resilient: bool = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._client = client
|
|
56
|
+
self._model = model
|
|
57
|
+
self._system = system
|
|
58
|
+
self._max_rounds = max_rounds
|
|
59
|
+
self._chat_fn = chat_fn
|
|
60
|
+
# resilient=True caches repeated calls and forces a final no-tools
|
|
61
|
+
# answer, for weaker models that loop on tools. Off by default.
|
|
62
|
+
self._resilient = resilient
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def client(self) -> ThingClient:
|
|
66
|
+
return self._client
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def tool_specs(self) -> list[dict[str, Any]]:
|
|
70
|
+
return self._client.list_actions()
|
|
71
|
+
|
|
72
|
+
async def chat(self, prompt: str) -> str:
|
|
73
|
+
"""Run the tool-calling loop for one prompt; return the final text
|
|
74
|
+
answer. See resilient for the weaker-model guards."""
|
|
75
|
+
return await self._run(prompt)
|
|
76
|
+
|
|
77
|
+
async def _run(self, user_content) -> str:
|
|
78
|
+
# user_content is a plain string or OpenAI multimodal content
|
|
79
|
+
# (a list of text/image_url parts), so a VLM host can pass images.
|
|
80
|
+
messages: list[dict[str, Any]] = []
|
|
81
|
+
if self._system:
|
|
82
|
+
messages.append({"role": "system", "content": self._system})
|
|
83
|
+
messages.append({"role": "user", "content": user_content})
|
|
84
|
+
|
|
85
|
+
tools = self._client.list_actions()
|
|
86
|
+
chat = self._chat_fn or self._litellm_chat
|
|
87
|
+
memo: dict[tuple, str] = {} # only used when resilient
|
|
88
|
+
|
|
89
|
+
for _ in range(self._max_rounds):
|
|
90
|
+
assistant = await chat(messages, tools)
|
|
91
|
+
messages.append(assistant)
|
|
92
|
+
tool_calls = assistant.get("tool_calls") or []
|
|
93
|
+
if not tool_calls:
|
|
94
|
+
return assistant.get("content") or ""
|
|
95
|
+
|
|
96
|
+
all_repeats = True
|
|
97
|
+
for call in tool_calls:
|
|
98
|
+
fn = call.get("function", {})
|
|
99
|
+
name = fn.get("name", "")
|
|
100
|
+
raw_args = fn.get("arguments") or "{}"
|
|
101
|
+
try:
|
|
102
|
+
args = json.loads(raw_args)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
args = {}
|
|
105
|
+
if self._resilient and (name, raw_args) in memo:
|
|
106
|
+
result_text = memo[(name, raw_args)] # cached; don't re-run
|
|
107
|
+
else:
|
|
108
|
+
all_repeats = False
|
|
109
|
+
result_text = to_text(await self._client.invoke(name, args))
|
|
110
|
+
if self._resilient:
|
|
111
|
+
memo[(name, raw_args)] = result_text
|
|
112
|
+
messages.append(
|
|
113
|
+
{
|
|
114
|
+
"role": "tool",
|
|
115
|
+
"tool_call_id": call.get("id", name),
|
|
116
|
+
"name": name,
|
|
117
|
+
"content": result_text,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# resilient: all calls were repeats, so force a no-tools turn.
|
|
122
|
+
if self._resilient and all_repeats:
|
|
123
|
+
final = await chat(messages, [])
|
|
124
|
+
return final.get("content") or _summary_from_memo(memo)
|
|
125
|
+
|
|
126
|
+
if self._resilient:
|
|
127
|
+
final = await chat(messages, [])
|
|
128
|
+
return final.get("content") or _summary_from_memo(memo)
|
|
129
|
+
return "(max tool rounds reached)"
|
|
130
|
+
|
|
131
|
+
async def monitor(
|
|
132
|
+
self,
|
|
133
|
+
event_or_property: str,
|
|
134
|
+
instruction: str,
|
|
135
|
+
*,
|
|
136
|
+
max_events: int = 10,
|
|
137
|
+
on_reaction=None,
|
|
138
|
+
) -> list[str]:
|
|
139
|
+
"""Subscribe and run an LLM turn per pushed value, with the
|
|
140
|
+
Thing's actions available. Returns per-event answers; stops after
|
|
141
|
+
max_events. on_reaction(value, answer) runs after each."""
|
|
142
|
+
stream = await self._client.subscribe(event_or_property)
|
|
143
|
+
reactions: list[str] = []
|
|
144
|
+
count = 0
|
|
145
|
+
async for value in stream:
|
|
146
|
+
answer = await self.chat(
|
|
147
|
+
f"{instruction}\n\nTelemetry just arrived from "
|
|
148
|
+
f"{event_or_property}: {to_text(value)}"
|
|
149
|
+
)
|
|
150
|
+
reactions.append(answer)
|
|
151
|
+
if on_reaction is not None:
|
|
152
|
+
res = on_reaction(value, answer)
|
|
153
|
+
if hasattr(res, "__await__"):
|
|
154
|
+
await res
|
|
155
|
+
count += 1
|
|
156
|
+
if count >= max_events:
|
|
157
|
+
break
|
|
158
|
+
return reactions
|
|
159
|
+
|
|
160
|
+
async def summarize_telemetry(
|
|
161
|
+
self,
|
|
162
|
+
event_or_property: str,
|
|
163
|
+
instruction: str,
|
|
164
|
+
*,
|
|
165
|
+
samples: int = 5,
|
|
166
|
+
) -> str:
|
|
167
|
+
"""Collect samples pushed values, then run one LLM turn over the
|
|
168
|
+
batch."""
|
|
169
|
+
stream = await self._client.subscribe(event_or_property)
|
|
170
|
+
collected: list[Any] = []
|
|
171
|
+
async for value in stream:
|
|
172
|
+
collected.append(value)
|
|
173
|
+
if len(collected) >= samples:
|
|
174
|
+
break
|
|
175
|
+
return await self.chat(
|
|
176
|
+
f"{instruction}\n\nHere are {len(collected)} telemetry "
|
|
177
|
+
f"readings from {event_or_property}: {to_text(collected)}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def _litellm_chat(
|
|
181
|
+
self,
|
|
182
|
+
messages: list[dict[str, Any]],
|
|
183
|
+
tools: list[dict[str, Any]],
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
import asyncio
|
|
186
|
+
|
|
187
|
+
import litellm # imported lazily; pure client has no LLM dep
|
|
188
|
+
|
|
189
|
+
resp = await asyncio.to_thread(
|
|
190
|
+
litellm.completion,
|
|
191
|
+
model=self._model,
|
|
192
|
+
messages=messages,
|
|
193
|
+
tools=tools or None,
|
|
194
|
+
tool_choice="auto" if tools else None,
|
|
195
|
+
)
|
|
196
|
+
msg = resp["choices"][0]["message"]
|
|
197
|
+
content = _get(msg, "content")
|
|
198
|
+
tool_calls = _get(msg, "tool_calls") or []
|
|
199
|
+
out: dict[str, Any] = {"role": "assistant"}
|
|
200
|
+
# An assistant turn with tool_calls must carry content=None (not
|
|
201
|
+
# ""), or some models re-issue the call instead of answering.
|
|
202
|
+
out["content"] = content if not tool_calls else (content or None)
|
|
203
|
+
if tool_calls:
|
|
204
|
+
out["tool_calls"] = [
|
|
205
|
+
{
|
|
206
|
+
"id": _get(tc, "id"),
|
|
207
|
+
"type": "function",
|
|
208
|
+
"function": {
|
|
209
|
+
"name": _get(_get(tc, "function"), "name"),
|
|
210
|
+
"arguments": _get(_get(tc, "function"), "arguments"),
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
for tc in tool_calls
|
|
214
|
+
]
|
|
215
|
+
return out
|