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 +87 -0
- dot_agora/_assistant.py +353 -0
- dot_agora/_clients/__init__.py +10 -0
- dot_agora/_clients/llm.py +177 -0
- dot_agora/_clients/search.py +56 -0
- dot_agora/_core/__init__.py +37 -0
- dot_agora/_core/events.py +131 -0
- dot_agora/_core/language.py +117 -0
- dot_agora/_core/mediator.py +194 -0
- dot_agora/_core/protocol.py +143 -0
- dot_agora/_core/state.py +174 -0
- dot_agora/_participant.py +195 -0
- dot_agora/_room/__init__.py +9 -0
- dot_agora/_room/room.py +186 -0
- dot_agora/_room/runtime.py +384 -0
- dot_agora/hosts/__init__.py +27 -0
- dot_agora/hosts/cli.py +572 -0
- dot_agora/hosts/http.py +175 -0
- dot_agora/hosts/local.py +20 -0
- dot_agora/hosts/terminal.py +201 -0
- dot_agora/hosts/wire.py +147 -0
- dot_agora/policies.py +108 -0
- dot_agora/protocols.py +151 -0
- dot_agora/views.py +101 -0
- dot_agora-2.0.0.dist-info/METADATA +241 -0
- dot_agora-2.0.0.dist-info/RECORD +28 -0
- dot_agora-2.0.0.dist-info/WHEEL +4 -0
- dot_agora-2.0.0.dist-info/licenses/LICENSE.md +660 -0
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
|
+
]
|
dot_agora/_assistant.py
ADDED
|
@@ -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
|