context-engineering-core 0.12.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.
- context_engine/__init__.py +15 -0
- context_engine/__main__.py +3 -0
- context_engine/_sqlite.py +19 -0
- context_engine/_windows_security.py +125 -0
- context_engine/_windows_storage.py +110 -0
- context_engine/adapters.py +148 -0
- context_engine/answers.py +90 -0
- context_engine/budget.py +93 -0
- context_engine/cli.py +222 -0
- context_engine/config.py +193 -0
- context_engine/context.py +116 -0
- context_engine/demo.py +98 -0
- context_engine/errors.py +91 -0
- context_engine/evaluation/__init__.py +1 -0
- context_engine/evaluation/commands.py +90 -0
- context_engine/evaluation/corpus.py +247 -0
- context_engine/evaluation/data/freeze.json +26 -0
- context_engine/evaluation/data/ground_truth.json +287 -0
- context_engine/evaluation/data/pin_retention.json +8 -0
- context_engine/evaluation/data/protocol.json +122 -0
- context_engine/evaluation/data/scenario.json +1226 -0
- context_engine/evaluation/data/summary_fixture.json +7 -0
- context_engine/evaluation/execution.py +538 -0
- context_engine/evaluation/fixtures.py +32 -0
- context_engine/evaluation/grading.py +62 -0
- context_engine/evaluation/harness.py +165 -0
- context_engine/evaluation/heldout/__init__.py +260 -0
- context_engine/evaluation/heldout/__main__.py +40 -0
- context_engine/evaluation/heldout/data/ground_truth.json +18 -0
- context_engine/evaluation/heldout/data/manifest.json +9 -0
- context_engine/evaluation/heldout/data/protocol.json +14 -0
- context_engine/evaluation/heldout/data/scenarios.json +18 -0
- context_engine/evaluation/heldout/data/scoring_manifest.json +4 -0
- context_engine/evaluation/heldout/data/scoring_protocol.json +12 -0
- context_engine/evaluation/heldout/scoring.py +176 -0
- context_engine/evaluation/protocol.py +264 -0
- context_engine/evaluation/reporting.py +294 -0
- context_engine/evaluation/results.py +331 -0
- context_engine/evaluation/run_commands.py +146 -0
- context_engine/evaluation/strategies.py +261 -0
- context_engine/inspection.py +266 -0
- context_engine/layers/__init__.py +18 -0
- context_engine/layers/cap.py +99 -0
- context_engine/layers/common.py +162 -0
- context_engine/layers/pin.py +68 -0
- context_engine/layers/retrieve.py +233 -0
- context_engine/layers/summarize.py +202 -0
- context_engine/layers/window.py +29 -0
- context_engine/mcp_server.py +267 -0
- context_engine/memory/__init__.py +5 -0
- context_engine/memory/codec.py +91 -0
- context_engine/memory/demo.py +90 -0
- context_engine/memory/store.py +770 -0
- context_engine/models.py +384 -0
- context_engine/pipeline.py +383 -0
- context_engine/providers/__init__.py +1 -0
- context_engine/providers/client.py +301 -0
- context_engine/providers/commands.py +138 -0
- context_engine/providers/contracts.py +324 -0
- context_engine/providers/credentials.py +66 -0
- context_engine/providers/memory.py +112 -0
- context_engine/providers/store.py +408 -0
- context_engine/providers/transport.py +195 -0
- context_engine/providers/windows_credentials.py +23 -0
- context_engine/py.typed +0 -0
- context_engine/service/__init__.py +1 -0
- context_engine/service/__main__.py +68 -0
- context_engine/service/app.py +404 -0
- context_engine/service/auth.py +132 -0
- context_engine/service/client.py +52 -0
- context_engine/service/control.py +134 -0
- context_engine/service/demo.py +151 -0
- context_engine/service/integrations.py +28 -0
- context_engine/service/schemas.py +59 -0
- context_engine/service/worker.py +59 -0
- context_engine/service/workers.py +156 -0
- context_engine/summaries.py +196 -0
- context_engine/tokens.py +171 -0
- context_engine/work.py +46 -0
- context_engineering_core-0.12.0.dist-info/METADATA +203 -0
- context_engineering_core-0.12.0.dist-info/RECORD +84 -0
- context_engineering_core-0.12.0.dist-info/WHEEL +4 -0
- context_engineering_core-0.12.0.dist-info/entry_points.txt +3 -0
- context_engineering_core-0.12.0.dist-info/licenses/LICENSE +202 -0
context_engine/models.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Immutable source and chat contracts. Scope is a label, never authorization."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import ContractError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def text_value(name: str, value: str, *, nonempty: bool = True) -> None:
|
|
14
|
+
if not isinstance(value, str) or (nonempty and not value.strip()):
|
|
15
|
+
raise ContractError("Expected text", field=name)
|
|
16
|
+
try:
|
|
17
|
+
value.encode("utf-8")
|
|
18
|
+
except UnicodeError:
|
|
19
|
+
raise ContractError("Text contains invalid Unicode", field=name) from None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def integer_value(name: str, value: int, *, minimum: int = 0) -> None:
|
|
23
|
+
if type(value) is not int or value < minimum:
|
|
24
|
+
raise ContractError("Expected an integer within range", field=name, minimum=minimum)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def aware_time(name: str, value: datetime) -> None:
|
|
28
|
+
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
|
29
|
+
raise ContractError("Timestamp must have a timezone", field=name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def canonical_json(value: Any) -> str:
|
|
33
|
+
try:
|
|
34
|
+
result = json.dumps(
|
|
35
|
+
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
36
|
+
)
|
|
37
|
+
result.encode("utf-8")
|
|
38
|
+
return result
|
|
39
|
+
except (TypeError, ValueError, UnicodeError, RecursionError):
|
|
40
|
+
raise ContractError("Value must be valid JSON with valid Unicode") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def object_json(value: str) -> str:
|
|
44
|
+
text_value("json", value)
|
|
45
|
+
|
|
46
|
+
def unique_object(pairs: list[tuple[str, Any]]) -> dict:
|
|
47
|
+
result = {}
|
|
48
|
+
for key, item in pairs:
|
|
49
|
+
if key in result:
|
|
50
|
+
raise ContractError("JSON object keys must be unique")
|
|
51
|
+
result[key] = item
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
parsed = json.loads(value, object_pairs_hook=unique_object)
|
|
56
|
+
except (ValueError, RecursionError):
|
|
57
|
+
raise ContractError("Expected a JSON object") from None
|
|
58
|
+
if not isinstance(parsed, dict):
|
|
59
|
+
raise ContractError("Expected a JSON object")
|
|
60
|
+
return canonical_json(parsed)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def freeze_sequence(instance: Any, name: str, item_type: type) -> tuple:
|
|
64
|
+
raw = getattr(instance, name)
|
|
65
|
+
if not isinstance(raw, (tuple, list)) or any(not isinstance(item, item_type) for item in raw):
|
|
66
|
+
raise ContractError("Invalid sequence contents", field=name)
|
|
67
|
+
frozen = tuple(raw)
|
|
68
|
+
object.__setattr__(instance, name, frozen)
|
|
69
|
+
return frozen
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Role(StrEnum):
|
|
73
|
+
SYSTEM = "system"
|
|
74
|
+
USER = "user"
|
|
75
|
+
ASSISTANT = "assistant"
|
|
76
|
+
TOOL = "tool"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BlockKind(StrEnum):
|
|
80
|
+
SYSTEM = "system"
|
|
81
|
+
PINNED = "pinned"
|
|
82
|
+
SUMMARY = "summary"
|
|
83
|
+
WINDOW = "window"
|
|
84
|
+
RETRIEVED = "retrieved"
|
|
85
|
+
QUESTION = "question"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class Scope:
|
|
90
|
+
tenant_id: str
|
|
91
|
+
session_id: str
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
text_value("tenant_id", self.tenant_id)
|
|
95
|
+
text_value("session_id", self.session_id)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class ToolCall:
|
|
100
|
+
call_id: str
|
|
101
|
+
name: str
|
|
102
|
+
arguments_json: str
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
text_value("call_id", self.call_id)
|
|
106
|
+
text_value("name", self.name)
|
|
107
|
+
object_json(self.arguments_json) # Validate without rewriting original call bytes.
|
|
108
|
+
|
|
109
|
+
def to_wire(self) -> dict:
|
|
110
|
+
return {
|
|
111
|
+
"id": self.call_id,
|
|
112
|
+
"type": "function",
|
|
113
|
+
"function": {"name": self.name, "arguments": self.arguments_json},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class ToolDefinition:
|
|
119
|
+
name: str
|
|
120
|
+
description: str
|
|
121
|
+
parameters_json: str
|
|
122
|
+
|
|
123
|
+
def __post_init__(self) -> None:
|
|
124
|
+
text_value("name", self.name)
|
|
125
|
+
text_value("description", self.description, nonempty=False)
|
|
126
|
+
object.__setattr__(self, "parameters_json", object_json(self.parameters_json))
|
|
127
|
+
|
|
128
|
+
def to_wire(self) -> dict:
|
|
129
|
+
return {
|
|
130
|
+
"type": "function",
|
|
131
|
+
"function": {
|
|
132
|
+
"name": self.name,
|
|
133
|
+
"description": self.description,
|
|
134
|
+
"parameters": json.loads(self.parameters_json),
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class Message:
|
|
141
|
+
message_id: str
|
|
142
|
+
role: Role
|
|
143
|
+
content: str
|
|
144
|
+
tool_call_id: str | None = None
|
|
145
|
+
tool_calls: tuple[ToolCall, ...] = ()
|
|
146
|
+
|
|
147
|
+
def __post_init__(self) -> None:
|
|
148
|
+
text_value("message_id", self.message_id)
|
|
149
|
+
text_value("content", self.content, nonempty=False)
|
|
150
|
+
try:
|
|
151
|
+
object.__setattr__(self, "role", Role(self.role))
|
|
152
|
+
except (ValueError, TypeError):
|
|
153
|
+
raise ContractError("Unsupported message role") from None
|
|
154
|
+
calls = freeze_sequence(self, "tool_calls", ToolCall)
|
|
155
|
+
if len({call.call_id for call in calls}) != len(calls):
|
|
156
|
+
raise ContractError("Duplicate tool call IDs")
|
|
157
|
+
if calls and self.role != Role.ASSISTANT:
|
|
158
|
+
raise ContractError("Only assistant messages can contain tool calls")
|
|
159
|
+
if self.role == Role.TOOL:
|
|
160
|
+
text_value("tool_call_id", self.tool_call_id)
|
|
161
|
+
elif self.tool_call_id is not None:
|
|
162
|
+
raise ContractError("Only tool results can carry tool_call_id")
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def content_hash(self) -> str:
|
|
166
|
+
return hashlib.sha256(self.content.encode("utf-8")).hexdigest()
|
|
167
|
+
|
|
168
|
+
def to_wire(self) -> dict:
|
|
169
|
+
result: dict = {"role": self.role.value, "content": self.content}
|
|
170
|
+
if self.tool_calls:
|
|
171
|
+
result["tool_calls"] = [call.to_wire() for call in self.tool_calls]
|
|
172
|
+
if self.tool_call_id is not None:
|
|
173
|
+
result["tool_call_id"] = self.tool_call_id
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True, slots=True)
|
|
178
|
+
class Turn:
|
|
179
|
+
turn_id: str
|
|
180
|
+
scope: Scope
|
|
181
|
+
messages: tuple[Message, ...]
|
|
182
|
+
revision: int = 1
|
|
183
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
184
|
+
|
|
185
|
+
def __post_init__(self) -> None:
|
|
186
|
+
text_value("turn_id", self.turn_id)
|
|
187
|
+
if not isinstance(self.scope, Scope):
|
|
188
|
+
raise ContractError("Turn requires a scope")
|
|
189
|
+
messages = freeze_sequence(self, "messages", Message)
|
|
190
|
+
if not messages or len({m.message_id for m in messages}) != len(messages):
|
|
191
|
+
raise ContractError("A turn requires messages with unique IDs")
|
|
192
|
+
integer_value("revision", self.revision, minimum=1)
|
|
193
|
+
aware_time("timestamp", self.timestamp)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def content_hash(self) -> str:
|
|
197
|
+
payload = [{"message_id": m.message_id, **m.to_wire()} for m in self.messages]
|
|
198
|
+
return hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class SourceRef:
|
|
203
|
+
scope: Scope
|
|
204
|
+
turn_id: str
|
|
205
|
+
message_id: str
|
|
206
|
+
revision: int
|
|
207
|
+
start: int
|
|
208
|
+
end: int
|
|
209
|
+
message_hash: str
|
|
210
|
+
|
|
211
|
+
def __post_init__(self) -> None:
|
|
212
|
+
if not isinstance(self.scope, Scope):
|
|
213
|
+
raise ContractError("Source requires a scope")
|
|
214
|
+
text_value("turn_id", self.turn_id)
|
|
215
|
+
text_value("message_id", self.message_id)
|
|
216
|
+
integer_value("revision", self.revision, minimum=1)
|
|
217
|
+
integer_value("start", self.start)
|
|
218
|
+
integer_value("end", self.end)
|
|
219
|
+
if self.end <= self.start:
|
|
220
|
+
raise ContractError("Source range must be nonempty and increasing")
|
|
221
|
+
if (
|
|
222
|
+
not isinstance(self.message_hash, str)
|
|
223
|
+
or len(self.message_hash) != 64
|
|
224
|
+
or any(c not in "0123456789abcdef" for c in self.message_hash)
|
|
225
|
+
):
|
|
226
|
+
raise ContractError("Source hash must be a lowercase SHA-256 digest")
|
|
227
|
+
|
|
228
|
+
def extract(self, turn: Turn) -> str:
|
|
229
|
+
"""Offsets are Unicode code points, validated against the original revision."""
|
|
230
|
+
if (turn.scope, turn.turn_id, turn.revision) != (self.scope, self.turn_id, self.revision):
|
|
231
|
+
raise ContractError("Source scope or revision mismatch")
|
|
232
|
+
message = next((m for m in turn.messages if m.message_id == self.message_id), None)
|
|
233
|
+
if (
|
|
234
|
+
message is None
|
|
235
|
+
or message.content_hash != self.message_hash
|
|
236
|
+
or self.end > len(message.content)
|
|
237
|
+
):
|
|
238
|
+
raise ContractError("Source message, hash or range mismatch")
|
|
239
|
+
return message.content[self.start : self.end]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True)
|
|
243
|
+
class Chunk:
|
|
244
|
+
chunk_id: str
|
|
245
|
+
source: SourceRef
|
|
246
|
+
content: str
|
|
247
|
+
chunker_version: str
|
|
248
|
+
|
|
249
|
+
def __post_init__(self) -> None:
|
|
250
|
+
text_value("chunk_id", self.chunk_id)
|
|
251
|
+
text_value("content", self.content)
|
|
252
|
+
text_value("chunker_version", self.chunker_version)
|
|
253
|
+
if not isinstance(self.source, SourceRef):
|
|
254
|
+
raise ContractError("Chunk requires a source reference")
|
|
255
|
+
if len(self.content) != self.source.end - self.source.start:
|
|
256
|
+
raise ContractError("Chunk length does not match source range")
|
|
257
|
+
|
|
258
|
+
def validate_source(self, turn: Turn) -> None:
|
|
259
|
+
if self.source.extract(turn) != self.content:
|
|
260
|
+
raise ContractError("Chunk content does not match original source")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True, slots=True)
|
|
264
|
+
class Pin:
|
|
265
|
+
scope: Scope
|
|
266
|
+
key: str
|
|
267
|
+
value: str
|
|
268
|
+
origin: str
|
|
269
|
+
revision: int = 1
|
|
270
|
+
effective_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
271
|
+
expires_at: datetime | None = None
|
|
272
|
+
source: SourceRef | None = None
|
|
273
|
+
|
|
274
|
+
def __post_init__(self) -> None:
|
|
275
|
+
if not isinstance(self.scope, Scope):
|
|
276
|
+
raise ContractError("Pin requires a scope")
|
|
277
|
+
for name in ("key", "value", "origin"):
|
|
278
|
+
text_value(name, getattr(self, name))
|
|
279
|
+
integer_value("revision", self.revision, minimum=1)
|
|
280
|
+
aware_time("effective_at", self.effective_at)
|
|
281
|
+
if self.expires_at is not None:
|
|
282
|
+
aware_time("expires_at", self.expires_at)
|
|
283
|
+
if self.expires_at <= self.effective_at:
|
|
284
|
+
raise ContractError("Pin expiry must follow its effective time")
|
|
285
|
+
if self.source is not None and (
|
|
286
|
+
not isinstance(self.source, SourceRef) or self.source.scope != self.scope
|
|
287
|
+
):
|
|
288
|
+
raise ContractError("Pin source scope mismatch")
|
|
289
|
+
|
|
290
|
+
def is_active(self, at: datetime) -> bool:
|
|
291
|
+
aware_time("at", at)
|
|
292
|
+
return self.effective_at <= at and (self.expires_at is None or at < self.expires_at)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass(frozen=True, slots=True)
|
|
296
|
+
class KeyedPins:
|
|
297
|
+
scope: Scope
|
|
298
|
+
pins: tuple[Pin, ...] = ()
|
|
299
|
+
|
|
300
|
+
def __post_init__(self) -> None:
|
|
301
|
+
if not isinstance(self.scope, Scope):
|
|
302
|
+
raise ContractError("Pins require a scope")
|
|
303
|
+
pins = freeze_sequence(self, "pins", Pin)
|
|
304
|
+
if any(pin.scope != self.scope for pin in pins):
|
|
305
|
+
raise ContractError("Pin scope mismatch")
|
|
306
|
+
if len({pin.key for pin in pins}) != len(pins):
|
|
307
|
+
raise ContractError("Pin keys must be unique; supersede old values before assembly")
|
|
308
|
+
object.__setattr__(self, "pins", tuple(sorted(pins, key=lambda pin: pin.key)))
|
|
309
|
+
|
|
310
|
+
def upsert(self, pin: Pin, *, expected_revision: int | None = None) -> "KeyedPins":
|
|
311
|
+
"""Immutable application-side update; transactional persistence remains C07."""
|
|
312
|
+
if not isinstance(pin, Pin) or pin.scope != self.scope:
|
|
313
|
+
raise ContractError("Pin scope mismatch")
|
|
314
|
+
existing = next((item for item in self.pins if item.key == pin.key), None)
|
|
315
|
+
if expected_revision is not None:
|
|
316
|
+
integer_value("expected_revision", expected_revision, minimum=1)
|
|
317
|
+
if existing is None:
|
|
318
|
+
if expected_revision is not None or pin.revision != 1:
|
|
319
|
+
raise ContractError("New pins must start at revision one")
|
|
320
|
+
elif expected_revision != existing.revision or pin.revision != existing.revision + 1:
|
|
321
|
+
raise ContractError("Pin revision conflict")
|
|
322
|
+
return KeyedPins(self.scope, tuple(p for p in self.pins if p.key != pin.key) + (pin,))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True, slots=True)
|
|
326
|
+
class ContextBlock:
|
|
327
|
+
kind: BlockKind
|
|
328
|
+
content: str
|
|
329
|
+
sources: tuple[SourceRef, ...] = ()
|
|
330
|
+
prefix_stable: bool = False
|
|
331
|
+
text_tokens: int | None = None
|
|
332
|
+
|
|
333
|
+
def __post_init__(self) -> None:
|
|
334
|
+
try:
|
|
335
|
+
object.__setattr__(self, "kind", BlockKind(self.kind))
|
|
336
|
+
except (ValueError, TypeError):
|
|
337
|
+
raise ContractError("Unsupported block kind") from None
|
|
338
|
+
text_value("content", self.content, nonempty=False)
|
|
339
|
+
freeze_sequence(self, "sources", SourceRef)
|
|
340
|
+
if type(self.prefix_stable) is not bool:
|
|
341
|
+
raise ContractError("prefix_stable must be a boolean")
|
|
342
|
+
if self.text_tokens is not None:
|
|
343
|
+
integer_value("text_tokens", self.text_tokens)
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def required(self) -> bool:
|
|
347
|
+
return self.kind in (BlockKind.SYSTEM, BlockKind.PINNED, BlockKind.QUESTION)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True, slots=True)
|
|
351
|
+
class ChatRequest:
|
|
352
|
+
messages: tuple[Message, ...]
|
|
353
|
+
tools: tuple[ToolDefinition, ...] = ()
|
|
354
|
+
|
|
355
|
+
def __post_init__(self) -> None:
|
|
356
|
+
messages = freeze_sequence(self, "messages", Message)
|
|
357
|
+
tools = freeze_sequence(self, "tools", ToolDefinition)
|
|
358
|
+
if not messages or len({m.message_id for m in messages}) != len(messages):
|
|
359
|
+
raise ContractError("Request requires messages with unique IDs")
|
|
360
|
+
if len({tool.name for tool in tools}) != len(tools):
|
|
361
|
+
raise ContractError("Tool definitions must have unique names")
|
|
362
|
+
pending: set[str] = set()
|
|
363
|
+
seen: set[str] = set()
|
|
364
|
+
for message in messages:
|
|
365
|
+
if message.role == Role.TOOL:
|
|
366
|
+
if message.tool_call_id not in pending:
|
|
367
|
+
raise ContractError("Tool result has no pending call")
|
|
368
|
+
pending.remove(message.tool_call_id)
|
|
369
|
+
else:
|
|
370
|
+
if pending:
|
|
371
|
+
raise ContractError("Tool results must complete before subsequent messages")
|
|
372
|
+
for call in message.tool_calls:
|
|
373
|
+
if call.call_id in seen:
|
|
374
|
+
raise ContractError("Tool call IDs must be unique across a request")
|
|
375
|
+
seen.add(call.call_id)
|
|
376
|
+
pending.add(call.call_id)
|
|
377
|
+
if pending:
|
|
378
|
+
raise ContractError("Request contains incomplete tool calls")
|
|
379
|
+
|
|
380
|
+
def to_wire(self) -> dict:
|
|
381
|
+
result = {"messages": [message.to_wire() for message in self.messages]}
|
|
382
|
+
if self.tools:
|
|
383
|
+
result["tools"] = [tool.to_wire() for tool in self.tools]
|
|
384
|
+
return result
|