forkreduce 0.1.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.
- forkreduce/__init__.py +76 -0
- forkreduce/checks.py +274 -0
- forkreduce/context.py +157 -0
- forkreduce/contrib/__init__.py +5 -0
- forkreduce/contrib/temporal.py +349 -0
- forkreduce/coverage.py +187 -0
- forkreduce/documents.py +173 -0
- forkreduce/errors.py +39 -0
- forkreduce/executor.py +200 -0
- forkreduce/failures.py +239 -0
- forkreduce/followup.py +215 -0
- forkreduce/models.py +48 -0
- forkreduce/plan.py +355 -0
- forkreduce/policy.py +147 -0
- forkreduce/prefix.py +90 -0
- forkreduce/py.typed +0 -0
- forkreduce/query.py +77 -0
- forkreduce/reduce.py +625 -0
- forkreduce/schemas.py +30 -0
- forkreduce/session.py +447 -0
- forkreduce/stats.py +92 -0
- forkreduce/textutil.py +21 -0
- forkreduce-0.1.0.dist-info/METADATA +631 -0
- forkreduce-0.1.0.dist-info/RECORD +26 -0
- forkreduce-0.1.0.dist-info/WHEEL +4 -0
- forkreduce-0.1.0.dist-info/licenses/LICENSE +21 -0
forkreduce/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""forkreduce: one frozen prefix, many queries, then a semantic reduce.
|
|
2
|
+
|
|
3
|
+
The framework is built on pydantic-ai. Pass any ``Model``. Nothing in this
|
|
4
|
+
package imports a workflow engine; the optional Temporal adapter lives in
|
|
5
|
+
``forkreduce.contrib.temporal``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from forkreduce.checks import CheckReport, CitationCheck, CitationRepair
|
|
11
|
+
from forkreduce.context import ContextEstimateWarning, ContextWarning
|
|
12
|
+
from forkreduce.coverage import AcceptUncovered, Provenance, ReduceReport
|
|
13
|
+
from forkreduce.documents import END_MARKER, Block, Document
|
|
14
|
+
from forkreduce.errors import ForkReduceError, PrefixMismatchWarning, UnregisteredOutputError
|
|
15
|
+
from forkreduce.executor import Executor, LocalExecutor
|
|
16
|
+
from forkreduce.failures import Failure, FailureKind
|
|
17
|
+
from forkreduce.followup import AcceptLeads, GapFill, LeadReport, RoundState, Scope
|
|
18
|
+
from forkreduce.models import openai_compatible_model
|
|
19
|
+
from forkreduce.plan import Plan, RoundRecord, RunResult
|
|
20
|
+
from forkreduce.policy import Accept, Budget, BudgetReport, Drop, Fail, Policy, RequireReason, Retry
|
|
21
|
+
from forkreduce.query import CheckFailure, Query, QueryResult
|
|
22
|
+
from forkreduce.reduce import ReduceItem, ReduceOutcome
|
|
23
|
+
from forkreduce.schemas import DiscardExplanation, DiscardExplanations, Traced
|
|
24
|
+
from forkreduce.session import Session, build
|
|
25
|
+
from forkreduce.stats import UsageSummary
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"END_MARKER",
|
|
31
|
+
"Accept",
|
|
32
|
+
"AcceptLeads",
|
|
33
|
+
"AcceptUncovered",
|
|
34
|
+
"Block",
|
|
35
|
+
"Budget",
|
|
36
|
+
"BudgetReport",
|
|
37
|
+
"CheckFailure",
|
|
38
|
+
"CheckReport",
|
|
39
|
+
"ContextEstimateWarning",
|
|
40
|
+
"ContextWarning",
|
|
41
|
+
"CitationCheck",
|
|
42
|
+
"CitationRepair",
|
|
43
|
+
"DiscardExplanation",
|
|
44
|
+
"DiscardExplanations",
|
|
45
|
+
"Document",
|
|
46
|
+
"Drop",
|
|
47
|
+
"Executor",
|
|
48
|
+
"Fail",
|
|
49
|
+
"Failure",
|
|
50
|
+
"FailureKind",
|
|
51
|
+
"ForkReduceError",
|
|
52
|
+
"GapFill",
|
|
53
|
+
"LeadReport",
|
|
54
|
+
"LocalExecutor",
|
|
55
|
+
"Plan",
|
|
56
|
+
"Policy",
|
|
57
|
+
"PrefixMismatchWarning",
|
|
58
|
+
"Provenance",
|
|
59
|
+
"Query",
|
|
60
|
+
"QueryResult",
|
|
61
|
+
"ReduceItem",
|
|
62
|
+
"ReduceOutcome",
|
|
63
|
+
"ReduceReport",
|
|
64
|
+
"RequireReason",
|
|
65
|
+
"Retry",
|
|
66
|
+
"RoundRecord",
|
|
67
|
+
"RoundState",
|
|
68
|
+
"RunResult",
|
|
69
|
+
"Scope",
|
|
70
|
+
"Session",
|
|
71
|
+
"Traced",
|
|
72
|
+
"UnregisteredOutputError",
|
|
73
|
+
"UsageSummary",
|
|
74
|
+
"build",
|
|
75
|
+
"openai_compatible_model",
|
|
76
|
+
]
|
forkreduce/checks.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Checks and repairs.
|
|
2
|
+
|
|
3
|
+
A check only reports. A repair builds a follow-up query, by default on the same
|
|
4
|
+
branch, and the framework copies only the allowed fields back onto the original
|
|
5
|
+
object. The model may return a full object; fields outside the allow-list are
|
|
6
|
+
ignored in code, not by asking the model nicely.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from forkreduce.documents import DocumentIndex
|
|
19
|
+
from forkreduce.errors import ForkReduceError
|
|
20
|
+
from forkreduce.query import CheckFailure, Query, QueryResult
|
|
21
|
+
from forkreduce.textutil import normalize_whitespace
|
|
22
|
+
|
|
23
|
+
_PATH_TOKEN = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)|\[(\d+)\]")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Check(Protocol):
|
|
27
|
+
"""Report problems with one successful output. Do not call a model."""
|
|
28
|
+
|
|
29
|
+
def __call__(self, output: Any, *, documents: DocumentIndex) -> list[CheckFailure]:
|
|
30
|
+
"""Return a failure for each problem. An empty list means the output passed."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Repair(Protocol):
|
|
35
|
+
"""Turn check failures into a follow-up query and a field-limited merge."""
|
|
36
|
+
|
|
37
|
+
max_rounds: int
|
|
38
|
+
|
|
39
|
+
def build_query(
|
|
40
|
+
self,
|
|
41
|
+
result: QueryResult[Any],
|
|
42
|
+
failures: Sequence[CheckFailure],
|
|
43
|
+
*,
|
|
44
|
+
round_index: int,
|
|
45
|
+
) -> Query[Any]:
|
|
46
|
+
"""Build the repair query. Default implementations continue ``result.messages``."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def apply(
|
|
50
|
+
self,
|
|
51
|
+
original: Any,
|
|
52
|
+
updated: Any,
|
|
53
|
+
failures: Sequence[CheckFailure],
|
|
54
|
+
) -> Any:
|
|
55
|
+
"""Return ``original`` with only the allowed fields taken from ``updated``."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class CitationCheck:
|
|
61
|
+
"""Locate each quote inside the block it cites.
|
|
62
|
+
|
|
63
|
+
Matching ignores differences in whitespace. The quote and block field names
|
|
64
|
+
default to ``quote`` and ``block_id``. Every nested model that has both
|
|
65
|
+
fields is checked.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
quote_field: str = "quote"
|
|
69
|
+
block_field: str = "block_id"
|
|
70
|
+
|
|
71
|
+
def __call__(self, output: Any, *, documents: DocumentIndex) -> list[CheckFailure]:
|
|
72
|
+
failures: list[CheckFailure] = []
|
|
73
|
+
for path, node in _iter_citations(output, self.quote_field, self.block_field):
|
|
74
|
+
quote = getattr(node, self.quote_field)
|
|
75
|
+
block_id = getattr(node, self.block_field)
|
|
76
|
+
quote_text = quote if isinstance(quote, str) else ""
|
|
77
|
+
block_text_id = block_id if isinstance(block_id, str) else ""
|
|
78
|
+
if not quote_text.strip():
|
|
79
|
+
failures.append(
|
|
80
|
+
CheckFailure(
|
|
81
|
+
code="empty_quote",
|
|
82
|
+
message=f"empty quote at {path}",
|
|
83
|
+
path=path,
|
|
84
|
+
block_id=block_text_id or None,
|
|
85
|
+
quote=quote_text,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
continue
|
|
89
|
+
if not block_text_id:
|
|
90
|
+
failures.append(
|
|
91
|
+
CheckFailure(
|
|
92
|
+
code="empty_block_id",
|
|
93
|
+
message=f"empty block_id at {path}",
|
|
94
|
+
path=path,
|
|
95
|
+
quote=quote_text,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
source = documents.block_text(block_text_id)
|
|
100
|
+
if source is None:
|
|
101
|
+
failures.append(
|
|
102
|
+
CheckFailure(
|
|
103
|
+
code="unknown_block",
|
|
104
|
+
message=f"block {block_text_id!r} is not in the frozen documents",
|
|
105
|
+
path=path,
|
|
106
|
+
block_id=block_text_id,
|
|
107
|
+
quote=quote_text,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
111
|
+
if normalize_whitespace(quote_text) not in normalize_whitespace(source):
|
|
112
|
+
failures.append(
|
|
113
|
+
CheckFailure(
|
|
114
|
+
code="quote_not_found",
|
|
115
|
+
message=f"quote at {path} was not found in block {block_text_id}",
|
|
116
|
+
path=path,
|
|
117
|
+
block_id=block_text_id,
|
|
118
|
+
quote=quote_text,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return failures
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class CitationRepair:
|
|
126
|
+
"""Repair citations by continuing the branch, then copying only allowed fields.
|
|
127
|
+
|
|
128
|
+
``max_rounds`` defaults to 2. The repair query asks for the same output type
|
|
129
|
+
the original query used, which is already on the frozen tool set. After the
|
|
130
|
+
model returns, :meth:`apply` copies ``allowed_fields`` and nothing else.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
max_rounds: int = 2
|
|
134
|
+
allowed_fields: tuple[str, ...] = ("quote", "block_id")
|
|
135
|
+
|
|
136
|
+
def __post_init__(self) -> None:
|
|
137
|
+
if self.max_rounds < 0:
|
|
138
|
+
raise ValueError("max_rounds must be >= 0")
|
|
139
|
+
if not self.allowed_fields:
|
|
140
|
+
raise ValueError("allowed_fields must not be empty")
|
|
141
|
+
|
|
142
|
+
def build_query(
|
|
143
|
+
self,
|
|
144
|
+
result: QueryResult[Any],
|
|
145
|
+
failures: Sequence[CheckFailure],
|
|
146
|
+
*,
|
|
147
|
+
round_index: int,
|
|
148
|
+
) -> Query[Any]:
|
|
149
|
+
if result.output is None:
|
|
150
|
+
raise ForkReduceError("cannot repair a query that has no output")
|
|
151
|
+
output_type = type(result.output)
|
|
152
|
+
lines = [
|
|
153
|
+
"Some citations could not be located in the frozen documents.",
|
|
154
|
+
"Return the same output type.",
|
|
155
|
+
"You may change only these fields: " + ", ".join(self.allowed_fields) + ".",
|
|
156
|
+
"Keep every other field identical to your previous answer.",
|
|
157
|
+
"",
|
|
158
|
+
"Failures:",
|
|
159
|
+
]
|
|
160
|
+
for failure in failures:
|
|
161
|
+
lines.append(
|
|
162
|
+
f"- path={failure.path} code={failure.code} "
|
|
163
|
+
f"block_id={failure.block_id or ''} quote={failure.quote or ''}"
|
|
164
|
+
)
|
|
165
|
+
return Query(
|
|
166
|
+
tail="\n".join(lines),
|
|
167
|
+
output=output_type,
|
|
168
|
+
id=f"{result.query_id}:repair:{round_index}",
|
|
169
|
+
history=list(result.messages),
|
|
170
|
+
meta={"repair_of": result.query_id, "repair_round": round_index},
|
|
171
|
+
round=result.round,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def apply(
|
|
175
|
+
self,
|
|
176
|
+
original: Any,
|
|
177
|
+
updated: Any,
|
|
178
|
+
failures: Sequence[CheckFailure],
|
|
179
|
+
) -> Any:
|
|
180
|
+
if not isinstance(original, BaseModel) or not isinstance(updated, BaseModel):
|
|
181
|
+
raise ForkReduceError("citation repair requires pydantic models")
|
|
182
|
+
if type(original) is not type(updated):
|
|
183
|
+
raise ForkReduceError(f"repair returned {type(updated).__name__}, expected {type(original).__name__}")
|
|
184
|
+
clone = original.model_copy(deep=True)
|
|
185
|
+
for failure in failures:
|
|
186
|
+
source = _resolve(updated, failure.path)
|
|
187
|
+
target = _resolve(clone, failure.path)
|
|
188
|
+
if not isinstance(source, BaseModel) or not isinstance(target, BaseModel):
|
|
189
|
+
continue
|
|
190
|
+
updates: dict[str, Any] = {}
|
|
191
|
+
for name in self.allowed_fields:
|
|
192
|
+
if name in type(target).model_fields and name in type(source).model_fields:
|
|
193
|
+
updates[name] = getattr(source, name)
|
|
194
|
+
if not updates:
|
|
195
|
+
continue
|
|
196
|
+
replaced = target.model_copy(update=updates)
|
|
197
|
+
clone = replaced if failure.path in {"", "$"} else _replace_at(clone, failure.path, replaced)
|
|
198
|
+
return clone
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class CheckReport:
|
|
203
|
+
"""What a check/repair pass did with one query. Kept even when the item is dropped."""
|
|
204
|
+
|
|
205
|
+
query_id: str
|
|
206
|
+
round: int
|
|
207
|
+
failures: list[CheckFailure]
|
|
208
|
+
repair_rounds: int
|
|
209
|
+
action: str
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _iter_citations(
|
|
213
|
+
value: Any,
|
|
214
|
+
quote_field: str,
|
|
215
|
+
block_field: str,
|
|
216
|
+
prefix: str = "",
|
|
217
|
+
) -> list[tuple[str, BaseModel]]:
|
|
218
|
+
found: list[tuple[str, BaseModel]] = []
|
|
219
|
+
if isinstance(value, BaseModel):
|
|
220
|
+
fields = type(value).model_fields
|
|
221
|
+
path = prefix or "$"
|
|
222
|
+
if quote_field in fields and block_field in fields:
|
|
223
|
+
found.append((path, value))
|
|
224
|
+
for name in fields:
|
|
225
|
+
child = getattr(value, name)
|
|
226
|
+
child_prefix = name if not prefix else f"{prefix}.{name}"
|
|
227
|
+
found.extend(_iter_citations(child, quote_field, block_field, child_prefix))
|
|
228
|
+
elif isinstance(value, list):
|
|
229
|
+
for index, child in enumerate(value):
|
|
230
|
+
found.extend(_iter_citations(child, quote_field, block_field, f"{prefix}[{index}]"))
|
|
231
|
+
return found
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _resolve(value: Any, path: str) -> Any:
|
|
235
|
+
if path in {"", "$"}:
|
|
236
|
+
return value
|
|
237
|
+
current = value
|
|
238
|
+
for match in _PATH_TOKEN.finditer(path):
|
|
239
|
+
name, index = match.group(1), match.group(2)
|
|
240
|
+
current = getattr(current, name) if name is not None else current[int(index)]
|
|
241
|
+
return current
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _replace_at(root: Any, path: str, replacement: Any) -> Any:
|
|
245
|
+
"""Return ``root`` with ``replacement`` stored at ``path``."""
|
|
246
|
+
tokens: list[str | int] = []
|
|
247
|
+
for match in _PATH_TOKEN.finditer(path):
|
|
248
|
+
name, index = match.group(1), match.group(2)
|
|
249
|
+
tokens.append(name if name is not None else int(index))
|
|
250
|
+
if not tokens:
|
|
251
|
+
return replacement
|
|
252
|
+
return _assign_tokens(root, tokens, replacement)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _assign_tokens(current: Any, tokens: list[str | int], replacement: Any) -> Any:
|
|
256
|
+
head, *rest = tokens
|
|
257
|
+
if not rest:
|
|
258
|
+
if isinstance(head, int):
|
|
259
|
+
if not isinstance(current, list):
|
|
260
|
+
raise ForkReduceError(f"expected a list while applying repair path, got {type(current)}")
|
|
261
|
+
current[head] = replacement
|
|
262
|
+
return current
|
|
263
|
+
if not isinstance(current, BaseModel):
|
|
264
|
+
raise ForkReduceError(f"expected a model while applying repair path, got {type(current)}")
|
|
265
|
+
return current.model_copy(update={head: replacement})
|
|
266
|
+
if isinstance(head, int):
|
|
267
|
+
if not isinstance(current, list):
|
|
268
|
+
raise ForkReduceError(f"expected a list while applying repair path, got {type(current)}")
|
|
269
|
+
current[head] = _assign_tokens(current[head], rest, replacement)
|
|
270
|
+
return current
|
|
271
|
+
if not isinstance(current, BaseModel):
|
|
272
|
+
raise ForkReduceError(f"expected a model while applying repair path, got {type(current)}")
|
|
273
|
+
child = _assign_tokens(getattr(current, head), rest, replacement)
|
|
274
|
+
return current.model_copy(update={head: child})
|
forkreduce/context.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Local context estimate.
|
|
2
|
+
|
|
3
|
+
The estimate is ``ceil(characters / 4)`` over the instructions, the tool schemas,
|
|
4
|
+
the documents or continuation history, and the query tail. It is not a provider
|
|
5
|
+
tokenizer, and it never blocks a call or truncates a prompt. When it exceeds the
|
|
6
|
+
configured window, forkreduce warns and still sends the request. Only a
|
|
7
|
+
provider-reported context-length error becomes a ``context_overflow`` failure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import warnings
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic_ai.messages import ModelMessage
|
|
20
|
+
|
|
21
|
+
from forkreduce.policy import Policy
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ContextEstimateWarning(UserWarning):
|
|
27
|
+
"""The local token estimate is over the configured context window.
|
|
28
|
+
|
|
29
|
+
The request is still sent. The provider decides whether it actually fits.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ContextWarning:
|
|
35
|
+
"""A local estimate that exceeded the configured window.
|
|
36
|
+
|
|
37
|
+
``where`` is ``prefix`` at build time and ``request`` for one query.
|
|
38
|
+
``reserve`` is the headroom held back from the prefix; request warnings
|
|
39
|
+
compare against the full ``limit`` and still record ``reserve``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
where: str
|
|
43
|
+
estimated_tokens: int
|
|
44
|
+
limit: int
|
|
45
|
+
reserve: int
|
|
46
|
+
message: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def estimate_tokens(text: str) -> int:
|
|
50
|
+
"""Rough token estimate: one token per four characters, rounded up."""
|
|
51
|
+
if not text:
|
|
52
|
+
return 0
|
|
53
|
+
return (len(text) + 3) // 4
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def effective_context_reserve(policy: Policy) -> int:
|
|
57
|
+
"""Tokens of headroom used when deciding whether a prefix should warn.
|
|
58
|
+
|
|
59
|
+
``Policy.context_reserve is None`` selects
|
|
60
|
+
``min(context_limit, max(4096, context_limit // 4))`` when a limit is set,
|
|
61
|
+
and 0 when it is not. An explicit integer, including 0, is used as given.
|
|
62
|
+
"""
|
|
63
|
+
if policy.context_reserve is not None:
|
|
64
|
+
return policy.context_reserve
|
|
65
|
+
limit = policy.context_limit
|
|
66
|
+
if limit is None:
|
|
67
|
+
return 0
|
|
68
|
+
return min(limit, max(4096, limit // 4))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def prefix_text(instructions: str, rendered_documents: str, tool_specs: Sequence[dict[str, Any]]) -> str:
|
|
72
|
+
"""Text standing in for the frozen prefix, including tool schemas."""
|
|
73
|
+
tools = json.dumps(list(tool_specs), sort_keys=True, default=str)
|
|
74
|
+
return f"{instructions}\n{rendered_documents}\n{tools}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def history_text(messages: Sequence[ModelMessage]) -> str:
|
|
78
|
+
"""Flatten a branch transcript into the characters that count toward the window."""
|
|
79
|
+
chunks: list[str] = []
|
|
80
|
+
for message in messages:
|
|
81
|
+
for part in getattr(message, "parts", []):
|
|
82
|
+
content = getattr(part, "content", None)
|
|
83
|
+
if isinstance(content, str):
|
|
84
|
+
chunks.append(content)
|
|
85
|
+
elif content is not None:
|
|
86
|
+
chunks.append(json.dumps(content, sort_keys=True, default=str))
|
|
87
|
+
return "\n".join(chunks)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def request_text(
|
|
91
|
+
*,
|
|
92
|
+
instructions: str,
|
|
93
|
+
rendered_documents: str,
|
|
94
|
+
tool_specs: Sequence[dict[str, Any]],
|
|
95
|
+
tail: str,
|
|
96
|
+
history: Sequence[ModelMessage] | None,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Characters for one model call.
|
|
99
|
+
|
|
100
|
+
Instructions and tool schemas are sent on every call even when they are not
|
|
101
|
+
in the message history. Continuation history already contains the documents,
|
|
102
|
+
so those are not added again.
|
|
103
|
+
"""
|
|
104
|
+
tools = json.dumps(list(tool_specs), sort_keys=True, default=str)
|
|
105
|
+
body = history_text(history) if history else rendered_documents
|
|
106
|
+
return f"{instructions}\n{tools}\n{body}\n{tail}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prefix_context_warning(policy: Policy, estimated_tokens: int) -> ContextWarning | None:
|
|
110
|
+
"""Warn when the frozen prefix exceeds ``context_limit - reserve``.
|
|
111
|
+
|
|
112
|
+
``context_limit is None`` skips the local warning. Equality is inside the window.
|
|
113
|
+
"""
|
|
114
|
+
limit = policy.context_limit
|
|
115
|
+
if limit is None:
|
|
116
|
+
return None
|
|
117
|
+
reserve = effective_context_reserve(policy)
|
|
118
|
+
usable = limit - reserve
|
|
119
|
+
if estimated_tokens <= usable:
|
|
120
|
+
return None
|
|
121
|
+
return ContextWarning(
|
|
122
|
+
where="prefix",
|
|
123
|
+
estimated_tokens=estimated_tokens,
|
|
124
|
+
limit=limit,
|
|
125
|
+
reserve=reserve,
|
|
126
|
+
message=(
|
|
127
|
+
f"context estimate: estimated {estimated_tokens} prefix tokens exceeds usable limit "
|
|
128
|
+
f"{usable} (context limit {limit} minus reserve {reserve})"
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def request_context_warning(policy: Policy, estimated_tokens: int) -> ContextWarning | None:
|
|
134
|
+
"""Warn when one full request estimate exceeds ``context_limit``.
|
|
135
|
+
|
|
136
|
+
The reserve is not subtracted here: the tail is already part of the estimate.
|
|
137
|
+
It is still recorded on the warning. ``context_limit is None`` skips the warning.
|
|
138
|
+
"""
|
|
139
|
+
limit = policy.context_limit
|
|
140
|
+
if limit is None or estimated_tokens <= limit:
|
|
141
|
+
return None
|
|
142
|
+
reserve = effective_context_reserve(policy)
|
|
143
|
+
return ContextWarning(
|
|
144
|
+
where="request",
|
|
145
|
+
estimated_tokens=estimated_tokens,
|
|
146
|
+
limit=limit,
|
|
147
|
+
reserve=reserve,
|
|
148
|
+
message=(
|
|
149
|
+
f"context estimate: estimated {estimated_tokens} tokens exceeds context limit {limit} (reserve {reserve})"
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def emit_context_warning(warning: ContextWarning, *, stacklevel: int = 2) -> None:
|
|
155
|
+
"""Emit ``warning`` through ``warnings`` and the ``forkreduce.context`` logger."""
|
|
156
|
+
warnings.warn(warning.message, ContextEstimateWarning, stacklevel=stacklevel)
|
|
157
|
+
logger.warning(warning.message)
|