dot-agora 2.0.0__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.
dot_agora/__init__.py ADDED
@@ -0,0 +1,87 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """dot_agora: mediated conversations between humans, LLM assistants and tools.
5
+
6
+ Everything listed in ``__all__`` is the public API, together with the catalog
7
+ modules (``dot_agora.protocols``, ``dot_agora.policies``, ``dot_agora.views``)
8
+ and the hosts (``dot_agora.hosts.*``). Underscore-prefixed modules are
9
+ internal and may change in any release.
10
+ """
11
+
12
+ from . import policies, protocols, views
13
+ from ._assistant import LLM, Assistant
14
+ from ._clients import LLMClient, LLMResult, SearchClient, Stream
15
+ from ._core import (
16
+ Act,
17
+ Decision,
18
+ Envelope,
19
+ Event,
20
+ Face,
21
+ Instance,
22
+ Journal,
23
+ Language,
24
+ Message,
25
+ Open,
26
+ ParticipantName,
27
+ Play,
28
+ Policy,
29
+ Post,
30
+ Protocol,
31
+ Refuse,
32
+ Role,
33
+ Ruling,
34
+ Speaker,
35
+ State,
36
+ Verdict,
37
+ lineage,
38
+ )
39
+ from ._participant import Participant, offers, serving
40
+ from ._room import Occupant, Reactor, Room, Session, Stepper, fork, resume
41
+ from .views import View, ViewFilter
42
+
43
+ __all__ = [
44
+ "LLM",
45
+ "Act",
46
+ "Assistant",
47
+ "Decision",
48
+ "Envelope",
49
+ "Event",
50
+ "Face",
51
+ "Instance",
52
+ "Journal",
53
+ "LLMClient",
54
+ "LLMResult",
55
+ "Language",
56
+ "Message",
57
+ "Occupant",
58
+ "Open",
59
+ "Participant",
60
+ "ParticipantName",
61
+ "Play",
62
+ "Policy",
63
+ "Post",
64
+ "Protocol",
65
+ "Reactor",
66
+ "Refuse",
67
+ "Role",
68
+ "Room",
69
+ "Ruling",
70
+ "SearchClient",
71
+ "Session",
72
+ "Speaker",
73
+ "State",
74
+ "Stepper",
75
+ "Stream",
76
+ "Verdict",
77
+ "View",
78
+ "ViewFilter",
79
+ "fork",
80
+ "lineage",
81
+ "offers",
82
+ "policies",
83
+ "protocols",
84
+ "resume",
85
+ "serving",
86
+ "views",
87
+ ]
@@ -0,0 +1,353 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """Assistant: the LLM extension. It is the participant that chooses whom to
5
+ call and what to ask, based on what its view shows. It adds no new concept
6
+ and no special rule; its conduct lives in this class.
7
+
8
+ Invariants:
9
+ Stateless. Nothing is kept between two reactions. The prompt is folded
10
+ from the view on each wake-up (`to_prompt`), so the same view gives the
11
+ same prompt (resume, replay).
12
+ Tools through the journal. The catalogue is the `offers(events)`
13
+ projection (the schemas declared in join payloads). A call is addressed
14
+ to whoever offers the tool.
15
+ The capability is its own. `llm=` accepts any
16
+ `(messages, tools) -> LLMResult`: the real client, or a deterministic
17
+ script (the scenarios). Same conduct, same journal.
18
+ Ask. A virtual `ask` tool is offered to the model. When the model calls
19
+ it, the assistant sends a request ({question, choices}) to the origin and
20
+ waits.
21
+ Schema. When the request carries one (`schema`) or the class sets one
22
+ (SCHEMA), the answer is checked here. A non-conforming answer becomes a
23
+ failure payload. The mediator knows nothing about it."""
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from collections.abc import Awaitable, Callable, Sequence
29
+ from typing import ClassVar
30
+
31
+ from ._clients import LLMClient, LLMResult
32
+ from ._core.events import Event, Message
33
+ from ._participant import Participant, offers, serving
34
+ from .protocols import PlaysApproval
35
+ from .views import View
36
+
37
+ LLM = Callable[[list[dict], list[dict]], Awaitable[LLMResult]]
38
+
39
+ ASK_TOOL = {
40
+ "name": "ask",
41
+ "description": "Ask the user a question when information is missing. "
42
+ "`choices` (optional) restricts the possible answers.",
43
+ "parameters": {
44
+ "type": "object",
45
+ "properties": {
46
+ "question": {"type": "string"},
47
+ "choices": {"type": "array", "items": {"type": "string"}},
48
+ },
49
+ "required": ["question"],
50
+ },
51
+ }
52
+
53
+
54
+ class Assistant(Participant, PlaysApproval): # PlaysApproval: it reads on_rejection
55
+ SCHEMA: dict | None = None # an answer schema set by the class
56
+ ASK_TOOL: ClassVar[dict] = ASK_TOOL # the HITL tool offered (overridable: a
57
+ # subclass can offer its own shape)
58
+ SYSTEM_PROMPT = (
59
+ "You are an assistant in a mediated conversation. You have no "
60
+ "memory: everything you know is in the messages above. "
61
+ "Other participants offer you tools. The `ask` tool asks the user "
62
+ "a question. Answer briefly, in the user's language."
63
+ )
64
+ LIMIT = 30 # events folded into the prompt
65
+ MAX_CHARS = 6000 # character budget per message
66
+ MAX_DELEGATION_STREAK = 8 # consecutive delegations before concluding
67
+
68
+ def __init__(self, name: str = "assistant", llm: LLM | None = None) -> None:
69
+ super().__init__(name)
70
+ self._llm = llm
71
+
72
+ @property
73
+ def llm(self) -> LLM:
74
+ """The capability, built on first use when none was given. This lets
75
+ `conversation.py` import without a key (the scenario passes a
76
+ scripted LLM)."""
77
+ if self._llm is None:
78
+ self._llm = LLMClient()
79
+ return self._llm
80
+
81
+ @llm.setter
82
+ def llm(self, value: LLM) -> None:
83
+ self._llm = value
84
+
85
+ # ── the acts I react to ───────────────────────────────────────────
86
+ async def on_request(self, event: Event, view: View) -> Sequence[Message]:
87
+ return await self.step(event, view)
88
+
89
+ on_reply = on_request
90
+ on_rejection = on_request # my delegation was rejected: a result, I conclude
91
+
92
+ async def on_say(self, event: Event, view: View) -> Sequence[Message]:
93
+ # A public say reaches me too (dispatch delivers addressed and public
94
+ # events). My conduct: I only speak when addressed.
95
+ return await self.step(event, view) if self.for_me(event) else []
96
+
97
+ # ── the conduct ───────────────────────────────────────────────────
98
+ def catalogue(self, events: tuple[Event, ...]) -> list[dict]:
99
+ """The tool catalogue offered to the LLM: the offers of those present
100
+ (a journal projection) plus `ASK_TOOL`. It is stable: no tool is added
101
+ or removed per mode, so the cache prefix stays stable. It is public so
102
+ that a summary (compaction) offers the same tools as the assistant, which
103
+ keeps the same prefix and KV cache. Tools come first in the prefix
104
+ (tools, then system, then messages)."""
105
+ cat = {n: ts for n, ts in offers(events).items() if n != self.name and ts}
106
+ return [t for ts in cat.values() for t in ts] + [self.ASK_TOOL]
107
+
108
+ async def step(self, event: Event, view: View) -> Sequence[Message]:
109
+ events = view()
110
+
111
+ # 1. The tool catalogue, from the journal (whom to address a call to).
112
+ cat = {n: ts for n, ts in offers(events).items() if n != self.name and ts}
113
+ tools = self.catalogue(events)
114
+ if self._delegation_streak(events) >= self.MAX_DELEGATION_STREAK:
115
+ tools = [] # enough digging: conclude
116
+
117
+ # 2. Is my batch of calls complete, and was I woken by its last
118
+ # reply? Otherwise I stay silent.
119
+ if self._waiting(events, event):
120
+ return []
121
+
122
+ # 3. The request I serve (the origin), and its schema if any.
123
+ origin = serving(self.name, event, events) or event
124
+ schema = self._schema(origin)
125
+ prompt = self.to_prompt(events)
126
+ if schema:
127
+ prompt.insert(
128
+ 1,
129
+ {
130
+ "role": "system",
131
+ "content": "Answer only with JSON that conforms to this "
132
+ f"schema: {json.dumps(schema)}",
133
+ },
134
+ )
135
+ out = await self.llm(prompt, tools)
136
+
137
+ # 4. The model asks for one or more tools: delegate through the
138
+ # journal with an act addressed to whoever offers the tool (traced,
139
+ # refusable). A tool nobody offers (invented) is ignored.
140
+ if out.calls:
141
+ outs: list[Message] = []
142
+ for tool, args in out.calls:
143
+ if tool == self.ASK_TOOL["name"]: # a request to the origin
144
+ return self._ask(args, origin)
145
+ provider = next(
146
+ (n for n, ts in cat.items() if any(t["name"] == tool for t in ts)), None
147
+ )
148
+ if provider:
149
+ outs += self.request({"tool": tool, "arguments": args}, to=(provider,))
150
+ if outs:
151
+ return outs
152
+
153
+ # 5. Otherwise, answer my debt (Participant.reply: `serving` finds,
154
+ # in my view, the request this answer serves).
155
+ text = out.text or "…"
156
+ if schema: # check here
157
+ return self.reply(event, view, self._conform(text, schema))
158
+ return self.reply(event, view, text)
159
+
160
+ def _ask(self, args: dict, origin: Event) -> list[Message]:
161
+ """Send the `ask` tool's question to the origin. Overridable: a subclass
162
+ can map a multi-question tool to a single `request`."""
163
+ return self.request(
164
+ {"question": args.get("question", "?"), "choices": list(args.get("choices", []))},
165
+ to=(origin.sender,),
166
+ )
167
+
168
+ # ── the view as a prompt ──────────────────────────────────────────
169
+ def to_prompt(self, events: tuple[Event, ...]) -> list[dict]:
170
+ """My view, in the model's four roles. What others say is `user`.
171
+ What I say is `assistant`. My tool calls are an `assistant` message
172
+ carrying `tool_calls` (one per call, `id` = `call_<idx>`). What
173
+ answers them is a `tool` message carrying that `id`. An event is a
174
+ result for me when it descends from one of my calls (caused_by), so
175
+ an interposition stays invisible.
176
+
177
+ Results are placed right after their call, whatever was said in
178
+ between: that is the shape the model expects. A call with no answer
179
+ yet is not shown."""
180
+ calls = self._my_calls(events)
181
+ cb = {e.idx: e.caused_by for e in events}
182
+ results: dict[int, list[Event]] = {}
183
+ for e in events:
184
+ if e.sender == self.name:
185
+ continue
186
+ call = self._ancestor_call(e.idx, calls, cb)
187
+ if call is not None:
188
+ results.setdefault(call, []).append(e)
189
+ msgs = [{"role": "system", "content": self.SYSTEM_PROMPT}]
190
+ window = [e for e in events[-self.LIMIT :] if e.act not in ("join", "leave")]
191
+ shown: set[int] = set()
192
+ i = 0
193
+ while i < len(window):
194
+ e = window[i]
195
+ if e.idx in shown:
196
+ i += 1
197
+ continue
198
+ if e.sender == self.name and self._is_call(e):
199
+ group = []
200
+ while (
201
+ i < len(window) and window[i].sender == self.name and self._is_call(window[i])
202
+ ):
203
+ group.append(window[i])
204
+ i += 1
205
+ msgs.extend(self._exchange(group, results, shown))
206
+ continue
207
+ role = "assistant" if e.sender == self.name else "user"
208
+ msgs.append({"role": role, "content": self._text(e.payload)})
209
+ i += 1
210
+ return msgs
211
+
212
+ def _exchange(
213
+ self, group: list[Event], results: dict[int, list[Event]], shown: set[int]
214
+ ) -> list[dict]:
215
+ """A batch of consecutive calls and their replies: one `assistant`
216
+ message carrying the answered `tool_calls`, then one `tool` message
217
+ per call. Several events for the same call are concatenated."""
218
+ answered = [c for c in group if results.get(c.idx)]
219
+ if not answered:
220
+ return []
221
+ out = [
222
+ {
223
+ "role": "assistant",
224
+ "content": None,
225
+ "tool_calls": [self._tool_call(c) for c in answered],
226
+ }
227
+ ]
228
+ for c in answered:
229
+ replies = results[c.idx]
230
+ shown.update(r.idx for r in replies)
231
+ out.append(
232
+ {
233
+ "role": "tool",
234
+ "tool_call_id": self.call_id(c),
235
+ "content": "\n".join(self._text(r.payload) for r in replies),
236
+ }
237
+ )
238
+ return out
239
+
240
+ @staticmethod
241
+ def call_id(call: Event) -> str:
242
+ """A call's id for the model: its journal idx. It is stable on replay
243
+ and unique in the conversation."""
244
+ return f"call_{call.idx}"
245
+
246
+ def _tool_call(self, call: Event) -> dict:
247
+ match call.payload:
248
+ case {"tool": tool, **fields}:
249
+ args = fields.get("arguments", {})
250
+ case _:
251
+ raise TypeError(f"event #{call.idx} is not a tool call")
252
+ return {
253
+ "id": self.call_id(call),
254
+ "type": "function",
255
+ "function": {
256
+ "name": tool,
257
+ "arguments": json.dumps(args, ensure_ascii=False, default=str),
258
+ },
259
+ }
260
+
261
+ def _text(self, payload: object) -> str:
262
+ """A payload as text for the model: as is for a string, JSON
263
+ otherwise. Truncated to `MAX_CHARS`."""
264
+ if payload is None:
265
+ return ""
266
+ text = (
267
+ payload
268
+ if isinstance(payload, str)
269
+ else json.dumps(payload, ensure_ascii=False, default=str)
270
+ )
271
+ return text[: self.MAX_CHARS]
272
+
273
+ # ── my calls and their replies, all derived ───────────────────────
274
+ @staticmethod
275
+ def _is_call(e: Event) -> bool:
276
+ return isinstance(e.payload, dict) and "tool" in e.payload
277
+
278
+ def _my_calls(self, events: tuple[Event, ...]) -> set[int]:
279
+ return {e.idx for e in events if e.sender == self.name and self._is_call(e)}
280
+
281
+ @staticmethod
282
+ def _ancestor_call(idx: int | None, calls: set[int], cb: dict[int, int | None]) -> int | None:
283
+ """My first ancestor call, walking `caused_by` up from `idx`. On the
284
+ rewired view the chain is view-local, so it skips a hidden
285
+ interposition on its own."""
286
+ while idx is not None:
287
+ if idx in calls:
288
+ return idx
289
+ idx = cb.get(idx)
290
+ return None
291
+
292
+ def _delegation_streak(self, events: tuple[Event, ...]) -> int:
293
+ """My delegations since the last real outside message. It guards
294
+ against getting stuck in a loop."""
295
+ calls = self._my_calls(events)
296
+ cb = {e.idx: e.caused_by for e in events}
297
+ n = 0
298
+ for e in reversed(events):
299
+ if e.act in ("join", "leave"):
300
+ continue
301
+ if e.sender == self.name and self._is_call(e):
302
+ n += 1
303
+ elif e.sender != self.name and self._ancestor_call(e.idx, calls, cb) is None:
304
+ break
305
+ return n
306
+
307
+ def _waiting(self, events: tuple[Event, ...], event: Event) -> bool:
308
+ """Should I stay silent? (a) One of my calls has no reply yet. Results
309
+ arrive in any order, so I wait. (b) All have replied, but a reply
310
+ arrived after the one waking me. That later wake-up concludes."""
311
+ calls = self._my_calls(events)
312
+ if not calls:
313
+ return False
314
+ cb = {e.idx: e.caused_by for e in events}
315
+ replies = [
316
+ (e, self._ancestor_call(e.idx, calls, cb))
317
+ for e in events
318
+ if e.sender != self.name and self.name in e.to
319
+ ]
320
+ replies = [(e, c) for e, c in replies if c is not None]
321
+ if calls - {c for _, c in replies}:
322
+ return True # (a) some are missing
323
+ return any(e.idx > event.idx for e, _ in replies) # (b) not my turn
324
+
325
+ # ── the schema: read from the request, or set by the class ────────
326
+ def _schema(self, origin: Event) -> dict | None:
327
+ schema = origin.payload.get("schema") if isinstance(origin.payload, dict) else None
328
+ return schema if isinstance(schema, dict) else self.SCHEMA
329
+
330
+ @staticmethod
331
+ def _conform(text: str, schema: dict) -> object:
332
+ """Minimal check (required keys, simple types). It is enough for a
333
+ failure to come back as an answer instead of invalid data."""
334
+ try:
335
+ data = json.loads(text)
336
+ except ValueError:
337
+ return {"error": "answer does not conform to the schema", "raw": text}
338
+ types = {
339
+ "string": str,
340
+ "integer": int,
341
+ "number": (int, float),
342
+ "boolean": bool,
343
+ "array": list,
344
+ "object": dict,
345
+ }
346
+ for k in schema.get("required", []):
347
+ if k not in data:
348
+ return {"error": f"missing key: {k}", "raw": text}
349
+ for k, spec in schema.get("properties", {}).items():
350
+ t = types.get(spec.get("type"))
351
+ if k in data and t and not isinstance(data[k], t):
352
+ return {"error": f"invalid type: {k}", "raw": text}
353
+ return data
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """clients: the capabilities a participant owns. The LLM, search, and the
5
+ stream contract."""
6
+
7
+ from .llm import LLMClient, LLMResult, Stream, Subscriber
8
+ from .search import SearchClient
9
+
10
+ __all__ = ["LLMClient", "LLMResult", "SearchClient", "Stream", "Subscriber"]
@@ -0,0 +1,177 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """llm: the LLM capability. A neutral contract behind a single boundary.
5
+
6
+ LLMResult what a call returns: text, or tool requests. Neutral
7
+ (dataclass + dicts), so a participant is written without
8
+ importing dot_inference.
9
+ Stream the contract of a capability that streams:
10
+ `subscribe(f, kind)`, with `kind` = "text" (default) or
11
+ "reasoning". Subscribers are hosts (a terminal printing word
12
+ by word, a front end showing the reasoning). The participant
13
+ only sees the complete result and the journal receives a
14
+ single event, so streaming is invisible to the framework and
15
+ to the model. Reasoning is display only: it is neither
16
+ returned nor journaled.
17
+ LLMClient the boundary: `await llm(messages, tools) -> LLMResult`. It
18
+ streams as soon as it has subscribers (Stream contract): each
19
+ fragment is published to them as it arrives, then the
20
+ complete result is returned. The participant only sees that
21
+ result.
22
+ The dot_inference client is resolved at construction, so a
23
+ missing configuration fails there rather than deep inside a
24
+ reading task; the translation to and from dot_inference
25
+ happens at call time, so this file is the only one that
26
+ moves when its interface changes.
27
+ A neutral message can carry `cache_control` (a cache
28
+ breakpoint set by the participant on its prompt). It travels
29
+ on the content block, the shape the provider reads."""
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import json
35
+ from collections.abc import Callable
36
+ from dataclasses import dataclass, field
37
+ from typing import Protocol, runtime_checkable
38
+
39
+ import dot_inference
40
+
41
+ Subscriber = Callable[[str], None]
42
+ KINDS = ("text", "reasoning")
43
+
44
+
45
+ @runtime_checkable
46
+ class Stream(Protocol):
47
+ def subscribe(self, f: Subscriber, kind: str = "text") -> None: ...
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class LLMResult:
52
+ """What an LLM call returns: text, or tool requests. `calls` holds all
53
+ the requests of the turn as (name, arguments) and is always filled.
54
+ Writing `LLMResult(tool=…, arguments=…)` (a convenience for fakes)
55
+ derives it. Readers only read `calls`."""
56
+
57
+ text: str | None = None
58
+ tool: str | None = None # write convenience for fakes only
59
+ arguments: dict = field(default_factory=dict)
60
+ calls: tuple = () # ((name, arguments), ...), the field to read
61
+
62
+ def __post_init__(self) -> None:
63
+ if not self.calls and self.tool:
64
+ object.__setattr__(self, "calls", ((self.tool, self.arguments),))
65
+
66
+
67
+ class LLMClient:
68
+ def __init__(
69
+ self,
70
+ model: str | None = None,
71
+ *,
72
+ reasoning_effort: str | None = None,
73
+ settings: dot_inference.InferenceSettings | None = None,
74
+ client: dot_inference.LLMClient | None = None,
75
+ ) -> None:
76
+ """Wrap a dot_inference client, given or built.
77
+
78
+ With ``client``, that client is used as is: several assistants can
79
+ share one connection pool and rate limiter, and tests pass a scripted
80
+ one. Otherwise dot_inference builds it from ``settings``, read from the
81
+ ``DOTI_*`` environment when omitted: the provider, the credentials and,
82
+ unless ``model`` is given, the model all come from there.
83
+ ``model`` and ``reasoning_effort`` only apply to a client built here.
84
+ """
85
+ if client is not None and (model, reasoning_effort, settings) != (None, None, None):
86
+ raise ValueError(
87
+ "LLMClient: model, reasoning_effort and settings configure a client "
88
+ "built here; they cannot be combined with client="
89
+ )
90
+ self._client = client or dot_inference.get_llm_client(
91
+ settings or dot_inference.InferenceSettings(),
92
+ model=model,
93
+ reasoning_effort=reasoning_effort,
94
+ )
95
+ self._subscribers: dict[str, list[Subscriber]] = {k: [] for k in KINDS}
96
+
97
+ # ── the stream: subscribers, served as fragments arrive ───────────
98
+ def subscribe(self, f: Subscriber, kind: str = "text") -> None:
99
+ """With a subscriber, calls go through `stream`. Without one, they go
100
+ through `call`. Same result, same translation."""
101
+ if kind not in KINDS:
102
+ raise ValueError(f"subscribe: kind {kind!r}, expected one of {KINDS}")
103
+ self._subscribers[kind].append(f)
104
+
105
+ def _publish(self, kind: str, fragment: str) -> None:
106
+ for f in self._subscribers[kind]:
107
+ with contextlib.suppress(Exception): # a failing display does not break the call
108
+ f(fragment)
109
+
110
+ # ── the call: translate, call, translate back ─────────────────────
111
+ async def __call__(self, messages: list[dict], tools: list[dict]) -> LLMResult:
112
+ msgs = [self._message(m) for m in messages]
113
+ tdefs = [
114
+ dot_inference.ToolDef(
115
+ name=t["name"], description=t["description"], parameters=t["parameters"]
116
+ )
117
+ for t in tools
118
+ ] or None
119
+
120
+ if any(self._subscribers.values()):
121
+ r = await self._stream(msgs, tdefs)
122
+ else:
123
+ r = await self._client.call(msgs, tools=tdefs, tool_choice="auto")
124
+
125
+ if r.tool_calls:
126
+ calls = tuple(
127
+ (
128
+ tc.name,
129
+ tc.arguments
130
+ if isinstance(tc.arguments, dict)
131
+ else json.loads(tc.arguments or "{}"),
132
+ )
133
+ for tc in r.tool_calls
134
+ )
135
+ return LLMResult(calls=calls)
136
+ return LLMResult(text=(r.content or "").strip())
137
+
138
+ @staticmethod
139
+ def _message(m: dict) -> dot_inference.Message:
140
+ """A neutral message to a dot_inference Message. Tool exchanges pass
141
+ through as is (`tool_calls` on an assistant, `tool_call_id` on a
142
+ `tool`): that is the provider's shape. A `cache_control` on the
143
+ message becomes a content block carrying it, which is where the
144
+ provider reads a cache breakpoint."""
145
+ Role = dot_inference.Role
146
+ roles = {
147
+ "user": Role.USER,
148
+ "assistant": Role.ASSISTANT,
149
+ "system": Role.SYSTEM,
150
+ "tool": Role.TOOL,
151
+ }
152
+ role = roles.get(m["role"], Role.USER)
153
+ extra = {k: m[k] for k in ("tool_calls", "tool_call_id") if m.get(k)}
154
+ content = m.get("content")
155
+ cc = m.get("cache_control")
156
+ if cc is not None and isinstance(content, str):
157
+ content = [{"type": "text", "text": content, "cache_control": cc}]
158
+ return dot_inference.Message(role=role, content=content, **extra)
159
+
160
+ async def _stream(
161
+ self, msgs: list[dot_inference.Message], tdefs: list[dot_inference.ToolDef] | None
162
+ ) -> dot_inference.LLMResponse:
163
+ """Consume the dot_inference stream. Publish each fragment (text or
164
+ reasoning) to its subscribers, then return the assembled response
165
+ (StreamDone). It is the same response as `call`, translated back by
166
+ the same code."""
167
+ response = None
168
+ async for ev in self._client.stream(msgs, tools=tdefs, tool_choice="auto"):
169
+ if isinstance(ev, dot_inference.TextDelta):
170
+ self._publish("text", ev.content)
171
+ elif isinstance(ev, dot_inference.ReasoningDelta):
172
+ self._publish("reasoning", ev.content)
173
+ elif isinstance(ev, dot_inference.StreamDone):
174
+ response = ev.response
175
+ if response is None:
176
+ raise RuntimeError("LLMClient: stream ended without StreamDone")
177
+ return response
@@ -0,0 +1,56 @@
1
+ # SPDX-FileCopyrightText: Kannon For Deep Tech
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ """search: the web search capability, behind a single boundary.
5
+
6
+ SearchClient `await search(query) -> str`: a sourced answer (text +
7
+ Markdown links). The search counterpart of LLMClient. The
8
+ key is checked at construction (a missing LINKUP_API_KEY
9
+ raises), and the provider call (Linkup) lives only here.
10
+
11
+ A participant that searches holds a SearchClient and has no key or HTTP code
12
+ of its own."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ import httpx
19
+
20
+ ENDPOINT = "https://api.linkup.so/v1/search"
21
+
22
+
23
+ class SearchClient:
24
+ DEPTH = "standard" # "deep": slower, more thorough
25
+ SOURCES = 3 # how many sources to cite
26
+ TIMEOUT = 30
27
+
28
+ def __init__(self, depth: str | None = None) -> None:
29
+ self.depth = depth or self.DEPTH
30
+ self.api_key = os.getenv("LINKUP_API_KEY")
31
+ if not self.api_key:
32
+ raise RuntimeError(
33
+ "SearchClient: LINKUP_API_KEY is not set in the environment. Real search requires it."
34
+ )
35
+
36
+ async def __call__(self, query: str) -> str:
37
+ """Search and return a sourced answer. An HTTP error response raises
38
+ `httpx.HTTPStatusError`."""
39
+ async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
40
+ r = await client.post(
41
+ ENDPOINT,
42
+ json={"q": query, "depth": self.depth, "outputType": "sourcedAnswer"},
43
+ headers={"Authorization": f"Bearer {self.api_key}"},
44
+ )
45
+ r.raise_for_status()
46
+ data = r.json()
47
+ return self._format(data)
48
+
49
+ def _format(self, data: dict) -> str:
50
+ answer = (data.get("answer") or "").strip()
51
+ sources = data.get("sources") or []
52
+ links = "\n".join(
53
+ f"- [{s.get('name', s.get('url', '?'))}]({s.get('url', '')})"
54
+ for s in sources[: self.SOURCES]
55
+ )
56
+ return f"{answer}\n\nSources:\n{links}" if links else answer