lorebase-guide 0.2.3__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.
- lorebase_guide/__init__.py +10 -0
- lorebase_guide/__main__.py +93 -0
- lorebase_guide/agent.py +337 -0
- lorebase_guide/app.py +451 -0
- lorebase_guide/auth.py +183 -0
- lorebase_guide/embed.py +74 -0
- lorebase_guide/keeper.py +97 -0
- lorebase_guide/limits.py +113 -0
- lorebase_guide/modes.py +156 -0
- lorebase_guide/prompts.py +89 -0
- lorebase_guide/settings.py +269 -0
- lorebase_guide/telemetry.py +42 -0
- lorebase_guide/tools.py +561 -0
- lorebase_guide/trace.py +68 -0
- lorebase_guide-0.2.3.dist-info/METADATA +15 -0
- lorebase_guide-0.2.3.dist-info/RECORD +18 -0
- lorebase_guide-0.2.3.dist-info/WHEEL +4 -0
- lorebase_guide-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Lorebase Guide: optional exploration assistant and query embeddings over Keeper.
|
|
2
|
+
|
|
3
|
+
Importing the package registers the `guide` settings section with `lorebase_platform`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from lorebase_guide.settings import GuideSettings, guide_settings
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.3"
|
|
9
|
+
|
|
10
|
+
__all__ = ["GuideSettings", "guide_settings"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Run Guide: `lorebase-guide` or `python -m lorebase_guide`.
|
|
2
|
+
|
|
3
|
+
Host, port and everything else come from the `guide` and `inference` settings.
|
|
4
|
+
|
|
5
|
+
lorebase-guide serve until stopped
|
|
6
|
+
lorebase-guide modes clear forget the structured output modes remembered per chat endpoint
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import uvicorn
|
|
19
|
+
|
|
20
|
+
from lorebase_guide.app import create_app
|
|
21
|
+
from lorebase_guide.modes import FileModeMemory, mode_memory_path
|
|
22
|
+
from lorebase_guide.settings import guide_settings
|
|
23
|
+
from lorebase_inference import inference_settings
|
|
24
|
+
from lorebase_inference.chat import use_mode_memory
|
|
25
|
+
from lorebase_platform.logging import configure_logging
|
|
26
|
+
from lorebase_platform.settings import ConfigError, load_settings
|
|
27
|
+
from lorebase_platform.telemetry import setup_telemetry
|
|
28
|
+
|
|
29
|
+
SERVICE_NAME = "guide"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parser() -> argparse.ArgumentParser:
|
|
33
|
+
parser = argparse.ArgumentParser(prog="lorebase-guide", description=__doc__.split("\n")[0])
|
|
34
|
+
commands = parser.add_subparsers(dest="command")
|
|
35
|
+
commands.add_parser("run", help="Serve until stopped (default).")
|
|
36
|
+
modes = commands.add_parser("modes", help="Structured output modes of the chat endpoints.")
|
|
37
|
+
modes.add_argument("action", choices=["clear"], help="Forget every remembered mode.")
|
|
38
|
+
return parser
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clear(path: Path) -> int:
|
|
42
|
+
try:
|
|
43
|
+
return FileModeMemory(path).clear()
|
|
44
|
+
except OSError as exc:
|
|
45
|
+
sys.exit(f"cannot clear {path}: {exc}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
49
|
+
"""Load settings, configure logging and telemetry, and serve until stopped (or run the
|
|
50
|
+
command)."""
|
|
51
|
+
args = _parser().parse_args(argv)
|
|
52
|
+
try:
|
|
53
|
+
settings = load_settings()
|
|
54
|
+
guide = guide_settings(settings)
|
|
55
|
+
inference = inference_settings(settings)
|
|
56
|
+
except ConfigError as exc:
|
|
57
|
+
sys.exit(str(exc))
|
|
58
|
+
if args.command == "modes":
|
|
59
|
+
path = mode_memory_path(guide.data_dir, guide.mode_memory_file)
|
|
60
|
+
cleared = 0 if path is None else _clear(path)
|
|
61
|
+
sys.stdout.write(json.dumps({"cleared": cleared}) + "\n")
|
|
62
|
+
return
|
|
63
|
+
configure_logging(settings.logging, service=SERVICE_NAME)
|
|
64
|
+
# httpx logs every request URL at INFO; keep it quiet so record keys stay out of the logs.
|
|
65
|
+
for name in ("httpx", "httpcore"):
|
|
66
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
67
|
+
telemetry = setup_telemetry(settings.telemetry)
|
|
68
|
+
modes = mode_memory_path(guide.data_dir, guide.mode_memory_file)
|
|
69
|
+
if modes is not None:
|
|
70
|
+
use_mode_memory(
|
|
71
|
+
FileModeMemory(modes),
|
|
72
|
+
secret=(
|
|
73
|
+
None
|
|
74
|
+
if inference.mode_memory_key is None
|
|
75
|
+
else inference.mode_memory_key.get_secret_value()
|
|
76
|
+
),
|
|
77
|
+
max_age_s=inference.mode_memory_ttl_s,
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
uvicorn.run(
|
|
81
|
+
create_app(guide, inference),
|
|
82
|
+
host=guide.host,
|
|
83
|
+
port=guide.port,
|
|
84
|
+
log_config=None,
|
|
85
|
+
access_log=False,
|
|
86
|
+
proxy_headers=False,
|
|
87
|
+
)
|
|
88
|
+
finally:
|
|
89
|
+
telemetry.shutdown()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
lorebase_guide/agent.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""The exploration loop: model turn, read-only tool call, repeat; bounded on every axis.
|
|
2
|
+
|
|
3
|
+
Budgets (from `guide.agent`, lowered per request, never raised):
|
|
4
|
+
|
|
5
|
+
- `max_steps` tool calls; when they are used up the model gets one last turn to answer;
|
|
6
|
+
- `max_tokens_per_call` completion tokens per model call;
|
|
7
|
+
- `max_total_tokens` input plus output tokens over the whole exploration; before each call the
|
|
8
|
+
input is estimated from the text length, and the call is only made when that estimate fits in
|
|
9
|
+
what remains, with a completion cap of at most what is left after it;
|
|
10
|
+
- `timeout_s` wall-clock time from the start, covering model calls and Keeper reads alike.
|
|
11
|
+
|
|
12
|
+
The loop yields `Event`s that the HTTP layer streams as server-sent events. Logs and metrics
|
|
13
|
+
carry counts, tool names and outcomes only: never the question, data or answers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
import secrets
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import AsyncIterator, Awaitable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from lorebase_client import AsyncLorebaseClient
|
|
29
|
+
from lorebase_client.errors import LorebaseError
|
|
30
|
+
from lorebase_guide import prompts
|
|
31
|
+
from lorebase_guide.embed import Embedder
|
|
32
|
+
from lorebase_guide.keeper import tool_scope
|
|
33
|
+
from lorebase_guide.settings import AgentSettings
|
|
34
|
+
from lorebase_guide.telemetry import STEPS, TOKENS, tracer
|
|
35
|
+
from lorebase_guide.tools import TOOLS, ToolContext, ToolResult, run_tool
|
|
36
|
+
from lorebase_guide.trace import TraceSession
|
|
37
|
+
from lorebase_inference import (
|
|
38
|
+
ChatModel,
|
|
39
|
+
ChatOptions,
|
|
40
|
+
InferenceError,
|
|
41
|
+
InvalidOutputError,
|
|
42
|
+
Message,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("lorebase.guide.agent")
|
|
46
|
+
|
|
47
|
+
_CHARS_PER_TOKEN = 4 # input estimate before a call; same ratio as the inference package
|
|
48
|
+
_MIN_CHARS_PER_TOKEN = 1.0 # floor of the ratio measured on a call
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Event:
|
|
53
|
+
"""One server-sent event: `name` and a JSON object."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
data: dict[str, Any]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Budget:
|
|
61
|
+
max_steps: int
|
|
62
|
+
max_total_tokens: int
|
|
63
|
+
max_tokens_per_call: int
|
|
64
|
+
timeout_s: float
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def of(cls, settings: AgentSettings, *, max_steps: int | None, budget: int | None) -> Budget:
|
|
68
|
+
return cls(
|
|
69
|
+
max_steps=min(max_steps or settings.max_steps, settings.max_steps),
|
|
70
|
+
max_total_tokens=min(budget or settings.max_total_tokens, settings.max_total_tokens),
|
|
71
|
+
max_tokens_per_call=settings.max_tokens_per_call,
|
|
72
|
+
timeout_s=settings.timeout_s,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class _Usage:
|
|
78
|
+
input_tokens: int = 0
|
|
79
|
+
output_tokens: int = 0
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def total(self) -> int:
|
|
83
|
+
return self.input_tokens + self.output_tokens
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Exploration:
|
|
88
|
+
"""Outcome counters, read by the HTTP layer for logs and metrics."""
|
|
89
|
+
|
|
90
|
+
steps: int = 0
|
|
91
|
+
outcome: str = "running"
|
|
92
|
+
usage: _Usage = field(default_factory=_Usage)
|
|
93
|
+
# Characters per input token: 4 until the endpoint reports its own count of a call, then
|
|
94
|
+
# what that call measured (JSON tool results ran at about 2.2 on Claude models).
|
|
95
|
+
chars_per_token: float = _CHARS_PER_TOKEN
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _StopError(Exception):
|
|
99
|
+
def __init__(self, code: str, message: str) -> None:
|
|
100
|
+
super().__init__(message)
|
|
101
|
+
self.code = code
|
|
102
|
+
self.message = message
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Explorer:
|
|
106
|
+
"""Runs explorations with one chat model; the Keeper client is per exploration."""
|
|
107
|
+
|
|
108
|
+
def __init__(self, chat: ChatModel, settings: AgentSettings, embedder: Embedder | None) -> None:
|
|
109
|
+
self._chat = chat
|
|
110
|
+
self._settings = settings
|
|
111
|
+
self._embedder = embedder
|
|
112
|
+
|
|
113
|
+
async def run(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
client: AsyncLorebaseClient,
|
|
117
|
+
trace: TraceSession,
|
|
118
|
+
domain: str,
|
|
119
|
+
branch: str | None,
|
|
120
|
+
question: str,
|
|
121
|
+
budget: Budget,
|
|
122
|
+
state: Exploration | None = None,
|
|
123
|
+
) -> AsyncIterator[Event]:
|
|
124
|
+
"""Events of one exploration, ending with `answer` (or `error`) and `done`."""
|
|
125
|
+
state = state or Exploration()
|
|
126
|
+
started = time.monotonic()
|
|
127
|
+
deadline = asyncio.get_running_loop().time() + budget.timeout_s
|
|
128
|
+
marker = secrets.token_hex(12)
|
|
129
|
+
ctx = ToolContext(
|
|
130
|
+
client=client,
|
|
131
|
+
domain=domain,
|
|
132
|
+
branch=branch,
|
|
133
|
+
settings=self._settings,
|
|
134
|
+
embedder=self._embedder,
|
|
135
|
+
)
|
|
136
|
+
messages = [
|
|
137
|
+
Message("system", prompts.system_prompt(marker)),
|
|
138
|
+
Message("user", prompts.question_prompt(domain, branch, question.replace(marker, ""))),
|
|
139
|
+
]
|
|
140
|
+
yield Event(
|
|
141
|
+
"session",
|
|
142
|
+
{
|
|
143
|
+
"session_id": trace.session_id,
|
|
144
|
+
"domain": domain,
|
|
145
|
+
"branch": branch,
|
|
146
|
+
"model": getattr(self._chat, "model", None),
|
|
147
|
+
"max_steps": budget.max_steps,
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
final: dict[str, Any] | None = None
|
|
151
|
+
calls = 0
|
|
152
|
+
try:
|
|
153
|
+
while final is None:
|
|
154
|
+
last_turn = state.steps >= budget.max_steps or not self._fits_another_step(
|
|
155
|
+
messages, budget, state, calls
|
|
156
|
+
)
|
|
157
|
+
if last_turn:
|
|
158
|
+
messages.append(Message("user", prompts.FINAL_CALL))
|
|
159
|
+
turn = await self._turn(messages, budget, state, deadline, final=last_turn)
|
|
160
|
+
calls += 1
|
|
161
|
+
thought = str(turn.get("thought") or "").strip()
|
|
162
|
+
if thought:
|
|
163
|
+
yield Event("thought", {"step": state.steps, "text": thought})
|
|
164
|
+
await _bounded(trace.note(client, thought), deadline)
|
|
165
|
+
action = str(turn.get("action") or "")
|
|
166
|
+
if action == prompts.ANSWER or last_turn:
|
|
167
|
+
final = self._final(turn if action == prompts.ANSWER else {}, ctx)
|
|
168
|
+
break
|
|
169
|
+
state.steps += 1
|
|
170
|
+
raw_args = turn.get("args")
|
|
171
|
+
args: dict[str, Any] = raw_args if isinstance(raw_args, dict) else {}
|
|
172
|
+
yield Event("tool_call", {"step": state.steps, "tool": action, "args": args})
|
|
173
|
+
result = await self._tool(ctx, trace, action, args, deadline)
|
|
174
|
+
yield Event(
|
|
175
|
+
"tool_result",
|
|
176
|
+
{
|
|
177
|
+
"step": state.steps,
|
|
178
|
+
"tool": action,
|
|
179
|
+
"ok": result.ok,
|
|
180
|
+
"summary": result.summary,
|
|
181
|
+
"refs": result.refs[: self._settings.max_items],
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
messages.append(Message("assistant", json.dumps(turn, ensure_ascii=False)))
|
|
185
|
+
messages.append(
|
|
186
|
+
Message(
|
|
187
|
+
"user",
|
|
188
|
+
prompts.data_block(
|
|
189
|
+
marker,
|
|
190
|
+
action,
|
|
191
|
+
result.ok,
|
|
192
|
+
result.data,
|
|
193
|
+
self._settings.max_result_chars,
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
except _StopError as stop:
|
|
198
|
+
state.outcome = stop.code
|
|
199
|
+
yield Event("error", {"code": stop.code, "message": stop.message})
|
|
200
|
+
final = {"text": prompts.NOT_FOUND, "citations": [], "found": False}
|
|
201
|
+
else:
|
|
202
|
+
state.outcome = "answered" if final["found"] else "not_found"
|
|
203
|
+
yield Event("answer", final)
|
|
204
|
+
yield Event(
|
|
205
|
+
"done",
|
|
206
|
+
{
|
|
207
|
+
"session_id": trace.session_id,
|
|
208
|
+
"steps": state.steps,
|
|
209
|
+
"outcome": state.outcome,
|
|
210
|
+
"tokens": {
|
|
211
|
+
"input": state.usage.input_tokens,
|
|
212
|
+
"output": state.usage.output_tokens,
|
|
213
|
+
},
|
|
214
|
+
"elapsed_ms": round((time.monotonic() - started) * 1000),
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _fits_another_step(
|
|
219
|
+
self, messages: list[Message], budget: Budget, state: Exploration, calls: int
|
|
220
|
+
) -> bool:
|
|
221
|
+
"""Whether the token budget still covers a tool step and the answer after it.
|
|
222
|
+
|
|
223
|
+
A tool step costs this call (estimated input, plus the output of an average call so
|
|
224
|
+
far) and leaves a larger context for the next one: this input plus at most
|
|
225
|
+
`max_result_chars` of tool result. The answer after it may use up to
|
|
226
|
+
`max_tokens_per_call`. When that does not fit, this turn is the last one, so the
|
|
227
|
+
exploration answers from what it read instead of stopping on the budget with nothing.
|
|
228
|
+
"""
|
|
229
|
+
estimate = math.ceil(_chars(messages) / state.chars_per_token)
|
|
230
|
+
output = (
|
|
231
|
+
math.ceil(state.usage.output_tokens / calls)
|
|
232
|
+
if calls
|
|
233
|
+
else budget.max_tokens_per_call // 4
|
|
234
|
+
)
|
|
235
|
+
result = math.ceil(self._settings.max_result_chars / state.chars_per_token)
|
|
236
|
+
needed = (estimate + output) + (estimate + output + result) + budget.max_tokens_per_call
|
|
237
|
+
return state.usage.total + needed <= budget.max_total_tokens
|
|
238
|
+
|
|
239
|
+
async def _turn(
|
|
240
|
+
self,
|
|
241
|
+
messages: list[Message],
|
|
242
|
+
budget: Budget,
|
|
243
|
+
state: Exploration,
|
|
244
|
+
deadline: float,
|
|
245
|
+
*,
|
|
246
|
+
final: bool,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
schema = prompts.turn_schema()
|
|
249
|
+
chars = _chars(messages)
|
|
250
|
+
remaining = (
|
|
251
|
+
budget.max_total_tokens - state.usage.total - math.ceil(chars / state.chars_per_token)
|
|
252
|
+
)
|
|
253
|
+
if remaining <= 0:
|
|
254
|
+
raise _StopError("budget", "the token budget of this exploration is used up")
|
|
255
|
+
options = ChatOptions(
|
|
256
|
+
task=self._settings.task,
|
|
257
|
+
temperature=self._settings.temperature,
|
|
258
|
+
max_tokens=min(budget.max_tokens_per_call, remaining),
|
|
259
|
+
)
|
|
260
|
+
try:
|
|
261
|
+
with tracer.start_as_current_span("guide.model"):
|
|
262
|
+
result = await _bounded(
|
|
263
|
+
self._chat.complete_json(messages, schema, options), deadline
|
|
264
|
+
)
|
|
265
|
+
except InferenceError as exc:
|
|
266
|
+
# The calls of an unusable answer were made and billed: count them. The problems
|
|
267
|
+
# are schema paths and keywords (for example `/: json`), never answer text.
|
|
268
|
+
detail: dict[str, Any] = {"error": type(exc).__name__}
|
|
269
|
+
if isinstance(exc, InvalidOutputError):
|
|
270
|
+
state.usage.input_tokens += exc.usage.input_tokens
|
|
271
|
+
state.usage.output_tokens += exc.usage.output_tokens
|
|
272
|
+
detail.update(attempts=exc.attempts, problems=list(exc.problems[:5]))
|
|
273
|
+
logger.warning("model call failed", extra=detail)
|
|
274
|
+
raise _StopError("model", "the model could not produce a valid step") from None
|
|
275
|
+
state.usage.input_tokens += result.usage.input_tokens
|
|
276
|
+
state.usage.output_tokens += result.usage.output_tokens
|
|
277
|
+
if result.usage.input_tokens > 0:
|
|
278
|
+
state.chars_per_token = min(
|
|
279
|
+
max(chars / result.usage.input_tokens, _MIN_CHARS_PER_TOKEN), _CHARS_PER_TOKEN
|
|
280
|
+
)
|
|
281
|
+
TOKENS.add(result.usage.input_tokens, {"direction": "input"})
|
|
282
|
+
TOKENS.add(result.usage.output_tokens, {"direction": "output"})
|
|
283
|
+
# The answer of the final turn is kept even when the endpoint counted more input than
|
|
284
|
+
# estimated: its tokens are spent either way, and no further call follows.
|
|
285
|
+
if state.usage.total > budget.max_total_tokens and not final:
|
|
286
|
+
raise _StopError("budget", "the token budget of this exploration is used up")
|
|
287
|
+
data = result.data
|
|
288
|
+
return data if isinstance(data, dict) else {}
|
|
289
|
+
|
|
290
|
+
async def _tool(
|
|
291
|
+
self,
|
|
292
|
+
ctx: ToolContext,
|
|
293
|
+
trace: TraceSession,
|
|
294
|
+
action: str,
|
|
295
|
+
args: dict[str, Any],
|
|
296
|
+
deadline: float,
|
|
297
|
+
) -> ToolResult:
|
|
298
|
+
with (
|
|
299
|
+
tracer.start_as_current_span("guide.tool") as span,
|
|
300
|
+
tool_scope(trace.tool_headers(action)),
|
|
301
|
+
):
|
|
302
|
+
span.set_attribute("guide.tool", action)
|
|
303
|
+
try:
|
|
304
|
+
result = await _bounded(run_tool(ctx, action, args), deadline)
|
|
305
|
+
except LorebaseError as exc:
|
|
306
|
+
result = ToolResult(False, f"Keeper error {exc.status}", {"error": "keeper"})
|
|
307
|
+
STEPS.add(1, {"tool": action if action in TOOLS else "unknown", "ok": result.ok})
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
def _final(self, turn: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
311
|
+
"""The answer, citing only Records a tool returned; without such a citation, or when
|
|
312
|
+
the model says so, the answer is that the graph does not contain it."""
|
|
313
|
+
raw = turn.get("citations")
|
|
314
|
+
cited: list[Any] = raw if isinstance(raw, list) else []
|
|
315
|
+
citations: list[dict[str, str]] = []
|
|
316
|
+
for ref in dict.fromkeys(str(c) for c in cited):
|
|
317
|
+
if ref in ctx.seen:
|
|
318
|
+
kind, _, key = ref.partition("/")
|
|
319
|
+
citations.append({"ref": ref, "kind": kind, "key": key})
|
|
320
|
+
text = str(turn.get("answer") or "").strip()[: self._settings.max_answer_chars]
|
|
321
|
+
found = bool(turn.get("found", True)) and bool(citations) and bool(text)
|
|
322
|
+
if not found:
|
|
323
|
+
return {"text": prompts.NOT_FOUND, "citations": [], "found": False}
|
|
324
|
+
return {"text": text, "citations": citations, "found": True}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _chars(messages: list[Message]) -> int:
|
|
328
|
+
"""Characters of a model call with these messages and the turn schema."""
|
|
329
|
+
return len(json.dumps(prompts.turn_schema())) + sum(len(m.content) for m in messages)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def _bounded[T](awaitable: Awaitable[T], deadline: float) -> T:
|
|
333
|
+
try:
|
|
334
|
+
async with asyncio.timeout_at(deadline):
|
|
335
|
+
return await awaitable
|
|
336
|
+
except TimeoutError:
|
|
337
|
+
raise _StopError("timeout", "the exploration took longer than allowed") from None
|