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 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)
@@ -0,0 +1,8 @@
1
+ """Optional helpers built on ThingClient. Import only what you use.
2
+
3
+ from thingctx.contrib import LLMHost # text tool-calling loop
4
+ """
5
+
6
+ from thingctx.contrib.llm import LLMHost
7
+
8
+ __all__ = ["LLMHost"]
@@ -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