subactor-shell 0.2.2__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.
- subactor_shell/__init__.py +3 -0
- subactor_shell/__main__.py +4 -0
- subactor_shell/acp_agent.py +363 -0
- subactor_shell/app.py +482 -0
- subactor_shell/artifacts.py +198 -0
- subactor_shell/catalog.py +416 -0
- subactor_shell/chat.py +517 -0
- subactor_shell/compiler.py +191 -0
- subactor_shell/config.py +374 -0
- subactor_shell/connectors.py +503 -0
- subactor_shell/context_builder.py +153 -0
- subactor_shell/control.py +141 -0
- subactor_shell/control_env.py +93 -0
- subactor_shell/intent_ir.py +187 -0
- subactor_shell/models.py +78 -0
- subactor_shell/operations.py +287 -0
- subactor_shell/orchestration.py +324 -0
- subactor_shell/policy.py +38 -0
- subactor_shell/providers/__init__.py +63 -0
- subactor_shell/providers/anthropic.py +101 -0
- subactor_shell/providers/base.py +81 -0
- subactor_shell/providers/mock.py +32 -0
- subactor_shell/providers/openai_compat.py +303 -0
- subactor_shell/providers/subactor_control.py +191 -0
- subactor_shell/redaction.py +62 -0
- subactor_shell/repl.py +480 -0
- subactor_shell/routing.py +334 -0
- subactor_shell/secret_refs.py +82 -0
- subactor_shell/store.py +857 -0
- subactor_shell/terminal.py +79 -0
- subactor_shell/token_budget.py +46 -0
- subactor_shell/vault.py +170 -0
- subactor_shell-0.2.2.dist-info/METADATA +449 -0
- subactor_shell-0.2.2.dist-info/RECORD +38 -0
- subactor_shell-0.2.2.dist-info/WHEEL +5 -0
- subactor_shell-0.2.2.dist-info/entry_points.txt +2 -0
- subactor_shell-0.2.2.dist-info/licenses/LICENSE +13 -0
- subactor_shell-0.2.2.dist-info/top_level.txt +1 -0
subactor_shell/chat.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import AsyncIterator, Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .artifacts import ArtifactManager, select_relevant_text
|
|
11
|
+
from .config import AppConfig
|
|
12
|
+
from .context_builder import ContextBuilder
|
|
13
|
+
from .models import Artifact, PreparedPrompt, Session
|
|
14
|
+
from .orchestration import OrchestrationOutcome, OrchestrationService
|
|
15
|
+
from .providers import ProviderBundle, build_provider
|
|
16
|
+
from .redaction import StreamingRedactor
|
|
17
|
+
from .secret_refs import SecretResolver
|
|
18
|
+
from .store import Store
|
|
19
|
+
from .token_budget import TokenUsage, estimate_text_tokens
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChatError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_ALIAS = r"[A-Za-z][A-Za-z0-9_.-]{0,63}"
|
|
27
|
+
SECRET_PATTERN = re.compile(r"\{\{secret:(" + _ALIAS + r")\}\}")
|
|
28
|
+
DATA_PATTERN = re.compile(r"\{\{data:(" + _ALIAS + r")\}\}")
|
|
29
|
+
ALIAS_PATTERN = re.compile(r"^" + _ALIAS + r"$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OneTimeGrantRegistry:
|
|
33
|
+
"""In-memory, process-local grants; intentionally never persisted."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._aliases: set[str] = set()
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
def grant(self, alias: str) -> None:
|
|
40
|
+
validate_alias(alias)
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._aliases.add(alias)
|
|
43
|
+
|
|
44
|
+
def consume(self, alias: str) -> bool:
|
|
45
|
+
with self._lock:
|
|
46
|
+
if alias not in self._aliases:
|
|
47
|
+
return False
|
|
48
|
+
self._aliases.remove(alias)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def list(self) -> list[str]:
|
|
52
|
+
with self._lock:
|
|
53
|
+
return sorted(self._aliases)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_alias(alias: str) -> str:
|
|
57
|
+
if not ALIAS_PATTERN.fullmatch(alias):
|
|
58
|
+
raise ValueError("Nazwa musi pasować do [A-Za-z][A-Za-z0-9_.-]{0,63}")
|
|
59
|
+
return alias
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ProviderBuilder = Callable[[Any, SecretResolver], ProviderBundle]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ChatService:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
config: AppConfig,
|
|
69
|
+
store: Store,
|
|
70
|
+
*,
|
|
71
|
+
resolver: SecretResolver | None = None,
|
|
72
|
+
provider_builder: ProviderBuilder = build_provider,
|
|
73
|
+
):
|
|
74
|
+
self.config = config
|
|
75
|
+
self.store = store
|
|
76
|
+
self.resolver = resolver or SecretResolver(config.vault)
|
|
77
|
+
self.provider_builder = provider_builder
|
|
78
|
+
self.grants = OneTimeGrantRegistry()
|
|
79
|
+
self.artifacts = ArtifactManager(
|
|
80
|
+
config.data_dir / "artifacts",
|
|
81
|
+
max_bytes=config.max_attachment_bytes,
|
|
82
|
+
max_text_chars=config.max_attachment_text_chars,
|
|
83
|
+
)
|
|
84
|
+
self.context_builder = ContextBuilder(store, config.context)
|
|
85
|
+
self.orchestration = OrchestrationService(
|
|
86
|
+
config,
|
|
87
|
+
store,
|
|
88
|
+
self.resolver,
|
|
89
|
+
provider_builder,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def new_session(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
name: str = "Nowa rozmowa",
|
|
96
|
+
provider: str | None = None,
|
|
97
|
+
model: str | None = None,
|
|
98
|
+
session_id: str | None = None,
|
|
99
|
+
) -> Session:
|
|
100
|
+
provider = provider or self.config.default_provider
|
|
101
|
+
profile = self.config.provider(provider)
|
|
102
|
+
model = model or profile.model or self.config.default_model
|
|
103
|
+
return self.store.create_session(name, provider, model, session_id=session_id)
|
|
104
|
+
|
|
105
|
+
def get_or_create_session(
|
|
106
|
+
self,
|
|
107
|
+
session_id: str | None,
|
|
108
|
+
*,
|
|
109
|
+
provider: str | None = None,
|
|
110
|
+
model: str | None = None,
|
|
111
|
+
) -> Session:
|
|
112
|
+
if session_id:
|
|
113
|
+
found = self.store.get_session(session_id)
|
|
114
|
+
if not found:
|
|
115
|
+
raise ChatError(f"Nie ma sesji {session_id}")
|
|
116
|
+
return found
|
|
117
|
+
return self.new_session(provider=provider, model=model)
|
|
118
|
+
|
|
119
|
+
def bind_secret(self, alias: str, reference: str) -> None:
|
|
120
|
+
validate_alias(alias)
|
|
121
|
+
if reference.startswith("vault://"):
|
|
122
|
+
from .vault import VaultRef
|
|
123
|
+
|
|
124
|
+
VaultRef.parse(reference)
|
|
125
|
+
elif not reference.startswith(("env://", "file://")):
|
|
126
|
+
raise ValueError("Sekret musi używać vault://, env:// albo file://")
|
|
127
|
+
self.store.bind_secret(alias, reference)
|
|
128
|
+
|
|
129
|
+
def grant_secret(self, alias: str) -> None:
|
|
130
|
+
validate_alias(alias)
|
|
131
|
+
if not self.store.get_secret_binding(alias):
|
|
132
|
+
raise ChatError(f"Brak bindingu sekretu '{alias}'")
|
|
133
|
+
self.grants.grant(alias)
|
|
134
|
+
|
|
135
|
+
def set_data_text(self, name: str, value: str) -> None:
|
|
136
|
+
validate_alias(name)
|
|
137
|
+
self.store.set_data(name, "text", value)
|
|
138
|
+
|
|
139
|
+
def set_data_file(self, name: str, source: Path, session_id: str | None = None) -> Artifact:
|
|
140
|
+
validate_alias(name)
|
|
141
|
+
artifact = self.artifacts.import_file(source)
|
|
142
|
+
if session_id:
|
|
143
|
+
self.store.add_artifact(artifact, session_id)
|
|
144
|
+
else:
|
|
145
|
+
raise ChatError("Do zapisu pliku danych wymagana jest aktywna sesja")
|
|
146
|
+
self.store.set_data(name, "artifact", artifact.id)
|
|
147
|
+
return artifact
|
|
148
|
+
|
|
149
|
+
def _expand_data(self, text: str, *, query: str) -> tuple[str, list[str]]:
|
|
150
|
+
names: list[str] = []
|
|
151
|
+
settings = self.config.context
|
|
152
|
+
max_chars = max(256, int(settings.get("max_data_chars", 6000)))
|
|
153
|
+
chunk_chars = max(256, int(settings.get("artifact_chunk_chars", 1800)))
|
|
154
|
+
max_chunks = max(1, int(settings.get("max_artifact_chunks", 4)))
|
|
155
|
+
|
|
156
|
+
def replace(match: re.Match[str]) -> str:
|
|
157
|
+
name = match.group(1)
|
|
158
|
+
item = self.store.get_data(name)
|
|
159
|
+
if not item:
|
|
160
|
+
raise ChatError(f"Brak danych '{{{{data:{name}}}}}'")
|
|
161
|
+
kind, value = item
|
|
162
|
+
names.append(name)
|
|
163
|
+
if kind == "text":
|
|
164
|
+
selected, truncated = select_relevant_text(
|
|
165
|
+
value,
|
|
166
|
+
query,
|
|
167
|
+
max_chars=max_chars,
|
|
168
|
+
chunk_chars=chunk_chars,
|
|
169
|
+
max_chunks=max_chunks,
|
|
170
|
+
)
|
|
171
|
+
return selected + ("\n[DATA_SELECTED_LOCALLY]" if truncated else "")
|
|
172
|
+
artifact = self.store.get_artifact(value)
|
|
173
|
+
if not artifact:
|
|
174
|
+
raise ChatError(f"Brak artefaktu danych '{name}' ({value})")
|
|
175
|
+
return self.artifacts.render_for_prompt(
|
|
176
|
+
artifact,
|
|
177
|
+
query=query,
|
|
178
|
+
max_chars=max_chars,
|
|
179
|
+
chunk_chars=chunk_chars,
|
|
180
|
+
max_chunks=max_chunks,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return DATA_PATTERN.sub(replace, text), sorted(set(names))
|
|
184
|
+
|
|
185
|
+
def _prepare_prompt(
|
|
186
|
+
self,
|
|
187
|
+
text: str,
|
|
188
|
+
attachments: list[Artifact],
|
|
189
|
+
additional_context_blocks: list[str] | None = None,
|
|
190
|
+
) -> PreparedPrompt:
|
|
191
|
+
additional_context_blocks = additional_context_blocks or []
|
|
192
|
+
|
|
193
|
+
# Only placeholders typed in the current user message can consume a
|
|
194
|
+
# one-time grant. Data, files and ACP resources remain untrusted text.
|
|
195
|
+
aliases = list(dict.fromkeys(SECRET_PATTERN.findall(text)))
|
|
196
|
+
resolved: dict[str, str] = {}
|
|
197
|
+
for alias in aliases:
|
|
198
|
+
reference = self.store.get_secret_binding(alias)
|
|
199
|
+
if not reference:
|
|
200
|
+
raise ChatError(f"Brak bindingu sekretu '{{{{secret:{alias}}}}}'")
|
|
201
|
+
if not self.grants.consume(alias):
|
|
202
|
+
raise ChatError(
|
|
203
|
+
f"Sekret '{alias}' wymaga jednorazowego grantu: /vault grant {alias}"
|
|
204
|
+
)
|
|
205
|
+
resolved[alias] = self.resolver.resolve(reference)
|
|
206
|
+
|
|
207
|
+
safe_text, data_names = self._expand_data(text, query=text)
|
|
208
|
+
provider_text_with_secrets = SECRET_PATTERN.sub(
|
|
209
|
+
lambda match: resolved[match.group(1)], text
|
|
210
|
+
)
|
|
211
|
+
provider_text, provider_data_names = self._expand_data(
|
|
212
|
+
provider_text_with_secrets, query=text
|
|
213
|
+
)
|
|
214
|
+
data_names = sorted(set(data_names) | set(provider_data_names))
|
|
215
|
+
|
|
216
|
+
settings = self.config.context
|
|
217
|
+
embedded_limit = max(0, int(settings.get("max_embedded_context_chars", 8000)))
|
|
218
|
+
safe_blocks = self.context_builder.compact_blocks(
|
|
219
|
+
list(additional_context_blocks), total_limit=embedded_limit
|
|
220
|
+
)
|
|
221
|
+
provider_blocks = list(safe_blocks)
|
|
222
|
+
if attachments:
|
|
223
|
+
attachment_limit = max(256, int(settings.get("max_attachment_prompt_chars", 8000)))
|
|
224
|
+
chunk_chars = max(256, int(settings.get("artifact_chunk_chars", 1800)))
|
|
225
|
+
max_chunks = max(1, int(settings.get("max_artifact_chunks", 4)))
|
|
226
|
+
rendered = [
|
|
227
|
+
self.artifacts.render_for_prompt(
|
|
228
|
+
item,
|
|
229
|
+
query=text,
|
|
230
|
+
max_chars=attachment_limit,
|
|
231
|
+
chunk_chars=chunk_chars,
|
|
232
|
+
max_chunks=max_chunks,
|
|
233
|
+
)
|
|
234
|
+
for item in attachments
|
|
235
|
+
]
|
|
236
|
+
rendered = self.context_builder.compact_blocks(
|
|
237
|
+
rendered, total_limit=attachment_limit
|
|
238
|
+
)
|
|
239
|
+
safe_blocks.extend(rendered)
|
|
240
|
+
provider_blocks.extend(rendered)
|
|
241
|
+
|
|
242
|
+
safe_context = safe_text
|
|
243
|
+
provider_content = provider_text
|
|
244
|
+
if safe_blocks:
|
|
245
|
+
safe_context += "\n\n" + "\n\n".join(safe_blocks)
|
|
246
|
+
provider_content += "\n\n" + "\n\n".join(provider_blocks)
|
|
247
|
+
|
|
248
|
+
display_content = text
|
|
249
|
+
if additional_context_blocks:
|
|
250
|
+
display_content += "\n\n" + "\n\n".join(additional_context_blocks)
|
|
251
|
+
|
|
252
|
+
return PreparedPrompt(
|
|
253
|
+
display_content=display_content,
|
|
254
|
+
safe_context_content=safe_context,
|
|
255
|
+
provider_content=provider_content,
|
|
256
|
+
resolved_secret_values=list(resolved.values()),
|
|
257
|
+
metadata={
|
|
258
|
+
"secret_aliases": aliases,
|
|
259
|
+
"data_names": data_names,
|
|
260
|
+
"artifact_ids": [item.id for item in attachments],
|
|
261
|
+
"embedded_context_blocks": len(additional_context_blocks),
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _prepare_local_message(
|
|
266
|
+
self,
|
|
267
|
+
text: str,
|
|
268
|
+
attachments: list[Artifact],
|
|
269
|
+
additional_context_blocks: list[str] | None,
|
|
270
|
+
) -> PreparedPrompt:
|
|
271
|
+
"""Persist a local route without reading or consuming any secret grant."""
|
|
272
|
+
|
|
273
|
+
additional_context_blocks = additional_context_blocks or []
|
|
274
|
+
safe_text, data_names = self._expand_data(text, query=text)
|
|
275
|
+
settings = self.config.context
|
|
276
|
+
blocks = self.context_builder.compact_blocks(
|
|
277
|
+
list(additional_context_blocks),
|
|
278
|
+
total_limit=max(0, int(settings.get("max_embedded_context_chars", 8000))),
|
|
279
|
+
)
|
|
280
|
+
if attachments:
|
|
281
|
+
attachment_limit = max(256, int(settings.get("max_attachment_prompt_chars", 8000)))
|
|
282
|
+
rendered = [
|
|
283
|
+
self.artifacts.render_for_prompt(
|
|
284
|
+
item,
|
|
285
|
+
query=text,
|
|
286
|
+
max_chars=attachment_limit,
|
|
287
|
+
chunk_chars=int(settings.get("artifact_chunk_chars", 1800)),
|
|
288
|
+
max_chunks=int(settings.get("max_artifact_chunks", 4)),
|
|
289
|
+
)
|
|
290
|
+
for item in attachments
|
|
291
|
+
]
|
|
292
|
+
blocks.extend(
|
|
293
|
+
self.context_builder.compact_blocks(rendered, total_limit=attachment_limit)
|
|
294
|
+
)
|
|
295
|
+
safe_context = safe_text + ("\n\n" + "\n\n".join(blocks) if blocks else "")
|
|
296
|
+
display = text + (
|
|
297
|
+
"\n\n" + "\n\n".join(additional_context_blocks)
|
|
298
|
+
if additional_context_blocks
|
|
299
|
+
else ""
|
|
300
|
+
)
|
|
301
|
+
return PreparedPrompt(
|
|
302
|
+
display_content=display,
|
|
303
|
+
safe_context_content=safe_context,
|
|
304
|
+
provider_content=safe_context,
|
|
305
|
+
resolved_secret_values=[],
|
|
306
|
+
metadata={
|
|
307
|
+
"secret_aliases": [],
|
|
308
|
+
"data_names": data_names,
|
|
309
|
+
"artifact_ids": [item.id for item in attachments],
|
|
310
|
+
"embedded_context_blocks": len(additional_context_blocks),
|
|
311
|
+
"local_route": True,
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def stream_message(
|
|
316
|
+
self,
|
|
317
|
+
session_id: str,
|
|
318
|
+
text: str,
|
|
319
|
+
*,
|
|
320
|
+
attachment_paths: list[Path] | None = None,
|
|
321
|
+
additional_context_blocks: list[str] | None = None,
|
|
322
|
+
cancel_event: asyncio.Event | None = None,
|
|
323
|
+
) -> AsyncIterator[str]:
|
|
324
|
+
session = self.store.get_session(session_id)
|
|
325
|
+
if not session:
|
|
326
|
+
raise ChatError(f"Nie ma sesji {session_id}")
|
|
327
|
+
if not text.strip() and not attachment_paths and not additional_context_blocks:
|
|
328
|
+
raise ChatError("Pusta wiadomość")
|
|
329
|
+
|
|
330
|
+
attachments: list[Artifact] = []
|
|
331
|
+
for path in attachment_paths or []:
|
|
332
|
+
artifact = self.artifacts.import_file(path)
|
|
333
|
+
self.store.add_artifact(artifact, session_id)
|
|
334
|
+
attachments.append(artifact)
|
|
335
|
+
|
|
336
|
+
outcome = await self.orchestration.prepare(
|
|
337
|
+
session,
|
|
338
|
+
text,
|
|
339
|
+
cancel_event=cancel_event,
|
|
340
|
+
)
|
|
341
|
+
route_metadata = {
|
|
342
|
+
"route": outcome.decision.route,
|
|
343
|
+
"intent_id": outcome.decision.intent_id,
|
|
344
|
+
"confidence": outcome.decision.confidence,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if outcome.direct_text:
|
|
348
|
+
prepared_local = self._prepare_local_message(
|
|
349
|
+
text,
|
|
350
|
+
attachments,
|
|
351
|
+
additional_context_blocks,
|
|
352
|
+
)
|
|
353
|
+
self.store.add_message(
|
|
354
|
+
session_id,
|
|
355
|
+
"user",
|
|
356
|
+
prepared_local.display_content,
|
|
357
|
+
prepared_local.safe_context_content,
|
|
358
|
+
prepared_local.metadata | {"routing": route_metadata},
|
|
359
|
+
)
|
|
360
|
+
self.store.add_message(
|
|
361
|
+
session_id,
|
|
362
|
+
"assistant",
|
|
363
|
+
outcome.direct_text,
|
|
364
|
+
outcome.direct_text,
|
|
365
|
+
{
|
|
366
|
+
"routing": route_metadata,
|
|
367
|
+
"plan_id": outcome.plan.id if outcome.plan else "",
|
|
368
|
+
"receipt_id": outcome.receipt.id if outcome.receipt else "",
|
|
369
|
+
"local": True,
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
intent = outcome.decision.intent
|
|
373
|
+
self.context_builder.update_state(
|
|
374
|
+
session_id,
|
|
375
|
+
user_text=text,
|
|
376
|
+
intent_id=intent.intent_id if intent else "",
|
|
377
|
+
constraints=intent.constraints if intent else None,
|
|
378
|
+
unresolved=intent.unresolved if intent else None,
|
|
379
|
+
receipt_id=outcome.receipt.id if outcome.receipt else "",
|
|
380
|
+
)
|
|
381
|
+
yield outcome.direct_text
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
prepared = self._prepare_prompt(
|
|
385
|
+
text,
|
|
386
|
+
attachments,
|
|
387
|
+
additional_context_blocks=additional_context_blocks,
|
|
388
|
+
)
|
|
389
|
+
context = self.context_builder.build(
|
|
390
|
+
session_id,
|
|
391
|
+
prepared.provider_content,
|
|
392
|
+
route_context=outcome.route_context,
|
|
393
|
+
)
|
|
394
|
+
user_metadata = prepared.metadata | {
|
|
395
|
+
"routing": route_metadata,
|
|
396
|
+
"context": {
|
|
397
|
+
"included_history_messages": context.included_history_messages,
|
|
398
|
+
"history_chars": context.history_chars,
|
|
399
|
+
"estimated_input_tokens": context.estimated_input_tokens,
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
self.store.add_message(
|
|
403
|
+
session_id,
|
|
404
|
+
"user",
|
|
405
|
+
prepared.display_content,
|
|
406
|
+
prepared.safe_context_content,
|
|
407
|
+
user_metadata,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
provider_name = outcome.provider or session.provider
|
|
411
|
+
profile = self.config.provider(provider_name)
|
|
412
|
+
model = outcome.model or (session.model if provider_name == session.provider else profile.model)
|
|
413
|
+
bundle = self.provider_builder(profile, self.resolver)
|
|
414
|
+
redactor = StreamingRedactor(
|
|
415
|
+
[*bundle.sensitive_values, *prepared.resolved_secret_values]
|
|
416
|
+
)
|
|
417
|
+
parts: list[str] = []
|
|
418
|
+
cancelled = False
|
|
419
|
+
stream_error: Exception | None = None
|
|
420
|
+
try:
|
|
421
|
+
async for raw in bundle.provider.stream(
|
|
422
|
+
context.messages,
|
|
423
|
+
model=model,
|
|
424
|
+
cancel_event=cancel_event,
|
|
425
|
+
):
|
|
426
|
+
safe = redactor.feed(raw)
|
|
427
|
+
if safe:
|
|
428
|
+
parts.append(safe)
|
|
429
|
+
yield safe
|
|
430
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
431
|
+
cancelled = True
|
|
432
|
+
break
|
|
433
|
+
cancelled = cancelled or bool(cancel_event is not None and cancel_event.is_set())
|
|
434
|
+
tail = redactor.finish()
|
|
435
|
+
if tail:
|
|
436
|
+
parts.append(tail)
|
|
437
|
+
yield tail
|
|
438
|
+
except asyncio.CancelledError:
|
|
439
|
+
cancelled = True
|
|
440
|
+
tail = redactor.finish()
|
|
441
|
+
if tail:
|
|
442
|
+
parts.append(tail)
|
|
443
|
+
raise
|
|
444
|
+
except Exception as exc:
|
|
445
|
+
stream_error = exc
|
|
446
|
+
raise
|
|
447
|
+
finally:
|
|
448
|
+
answer = "".join(parts)
|
|
449
|
+
provider_usage = getattr(bundle.provider, "last_usage", None)
|
|
450
|
+
if not isinstance(provider_usage, TokenUsage):
|
|
451
|
+
provider_usage = TokenUsage(
|
|
452
|
+
input_tokens=context.estimated_input_tokens,
|
|
453
|
+
output_tokens=estimate_text_tokens(answer),
|
|
454
|
+
estimated=True,
|
|
455
|
+
)
|
|
456
|
+
else:
|
|
457
|
+
if provider_usage.input_tokens <= 0:
|
|
458
|
+
provider_usage.input_tokens = context.estimated_input_tokens
|
|
459
|
+
provider_usage.estimated = True
|
|
460
|
+
if provider_usage.output_tokens <= 0 and answer:
|
|
461
|
+
provider_usage.output_tokens = estimate_text_tokens(answer)
|
|
462
|
+
provider_usage.estimated = True
|
|
463
|
+
self.store.record_provider_usage(
|
|
464
|
+
session_id,
|
|
465
|
+
provider=provider_name,
|
|
466
|
+
model=model,
|
|
467
|
+
purpose="chat_response",
|
|
468
|
+
usage=provider_usage,
|
|
469
|
+
input_cost_per_million=profile.input_cost_per_million,
|
|
470
|
+
cached_input_cost_per_million=profile.cached_input_cost_per_million,
|
|
471
|
+
output_cost_per_million=profile.output_cost_per_million,
|
|
472
|
+
metadata={
|
|
473
|
+
"route": outcome.decision.route,
|
|
474
|
+
"cancelled": cancelled,
|
|
475
|
+
"error": type(stream_error).__name__ if stream_error else "",
|
|
476
|
+
},
|
|
477
|
+
)
|
|
478
|
+
if answer:
|
|
479
|
+
self.store.add_message(
|
|
480
|
+
session_id,
|
|
481
|
+
"assistant",
|
|
482
|
+
answer,
|
|
483
|
+
answer,
|
|
484
|
+
{
|
|
485
|
+
"cancelled": cancelled,
|
|
486
|
+
"routing": route_metadata,
|
|
487
|
+
"usage": provider_usage.to_dict(),
|
|
488
|
+
},
|
|
489
|
+
)
|
|
490
|
+
intent = outcome.decision.intent
|
|
491
|
+
self.context_builder.update_state(
|
|
492
|
+
session_id,
|
|
493
|
+
user_text=text,
|
|
494
|
+
intent_id=intent.intent_id if intent else "",
|
|
495
|
+
constraints=intent.constraints if intent else None,
|
|
496
|
+
unresolved=intent.unresolved if intent else None,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
async def complete_message(
|
|
500
|
+
self,
|
|
501
|
+
session_id: str,
|
|
502
|
+
text: str,
|
|
503
|
+
*,
|
|
504
|
+
attachment_paths: list[Path] | None = None,
|
|
505
|
+
additional_context_blocks: list[str] | None = None,
|
|
506
|
+
cancel_event: asyncio.Event | None = None,
|
|
507
|
+
) -> str:
|
|
508
|
+
parts: list[str] = []
|
|
509
|
+
async for chunk in self.stream_message(
|
|
510
|
+
session_id,
|
|
511
|
+
text,
|
|
512
|
+
attachment_paths=attachment_paths,
|
|
513
|
+
additional_context_blocks=additional_context_blocks,
|
|
514
|
+
cancel_event=cancel_event,
|
|
515
|
+
):
|
|
516
|
+
parts.append(chunk)
|
|
517
|
+
return "".join(parts)
|