ccx-native 0.0.1 → 0.2.0

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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,397 @@
1
+ from __future__ import annotations
2
+ import base64
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from typing import Any
7
+ from ccx.store.liveness import recorded_host_loaded
8
+ from ccx.client import CcxClient
9
+ from ccx.codex.client import CodexClient
10
+ from ccx.errors import (
11
+ InvalidRequest,
12
+ SessionUnavailable,
13
+ worker_not_found,
14
+ )
15
+ from ccx.workers import WorkerClient
16
+ from ccx.store.identity import canonical_worker_id
17
+
18
+
19
+ _TRANSCRIPT_LOCATOR_PREFIX = "ccxt2."
20
+
21
+
22
+ _TRANSCRIPT_CURSOR_PREFIX = "ccxtc2."
23
+
24
+
25
+ _DEFAULT_TRANSCRIPT_PAGE = 20
26
+
27
+
28
+ _DEFAULT_TRANSCRIPT_TOOL_BYTES = 64 * 1024
29
+
30
+
31
+ _DEFAULT_REPORT_CHARS = 64 * 1024
32
+
33
+
34
+ MAX_CALLER_METADATA_BYTES = 4 * 1024
35
+
36
+
37
+ _ENGINES = frozenset({"claude", "codex"})
38
+
39
+
40
+ _PERMISSIONS = frozenset({"full", "restricted"})
41
+
42
+
43
+ class _LazyClient:
44
+ """Keep observational protocol imports free of directory initialization."""
45
+
46
+ def __init__(self, factory):
47
+ self.factory = factory
48
+ self.value = None
49
+
50
+ def __getattr__(self, name):
51
+ if self.value is None:
52
+ self.value = self.factory()
53
+ return getattr(self.value, name)
54
+
55
+
56
+ _CLIENT = _LazyClient(CcxClient)
57
+
58
+
59
+ _CODEX_CLIENT = _LazyClient(CodexClient)
60
+
61
+
62
+ _WORKERS = _LazyClient(WorkerClient)
63
+
64
+
65
+ def _normalize_caller_metadata(
66
+ value: dict[str, Any] | None,
67
+ *,
68
+ notify_parent: bool,
69
+ ) -> dict[str, Any] | None:
70
+ if value is None:
71
+ return None
72
+ if not notify_parent:
73
+ raise InvalidRequest(
74
+ "send: caller_metadata requires notify_parent=true"
75
+ )
76
+ if not isinstance(value, dict):
77
+ raise InvalidRequest("send: caller_metadata must be a JSON object")
78
+ try:
79
+ encoded = json.dumps(
80
+ value,
81
+ sort_keys=True,
82
+ separators=(",", ":"),
83
+ ensure_ascii=False,
84
+ allow_nan=False,
85
+ ).encode("utf-8")
86
+ except (TypeError, ValueError, OverflowError) as error:
87
+ raise InvalidRequest(
88
+ "send: caller_metadata must contain only valid JSON values"
89
+ ) from error
90
+ if len(encoded) > MAX_CALLER_METADATA_BYTES:
91
+ raise InvalidRequest(
92
+ "send: caller_metadata exceeds the 4096-byte serialized limit"
93
+ )
94
+ # Return a detached, JSON-normalized object so later caller mutation cannot
95
+ # change the durable semantic value between receipt and notification writes.
96
+ return json.loads(encoded.decode("utf-8"))
97
+
98
+
99
+ def split_target(target: str) -> tuple[str, str, str]:
100
+ """Exact globally owned full UUID; never short/name/provider aliases.
101
+
102
+ Direct protocol helper now requires the same sealed ACTIVE generation as
103
+ CLI, one-shot and direct Python. It returns (full handle, engine, full ID),
104
+ not the previous short. No native discovery or legacy record fallback.
105
+ """
106
+ from ccx.constants import CCX_ROOT
107
+ from ccx.store.root import GenerationError, require_ccx_active
108
+ from ccx.store.identity import UnknownWorker, IdentityError
109
+ try:
110
+ store = require_ccx_active(CCX_ROOT)
111
+ owner = store.resolve(target)
112
+ return owner.worker, owner.engine, owner.worker_id
113
+ except UnknownWorker as exc:
114
+ raise worker_not_found(target) from exc
115
+ except IdentityError as exc:
116
+ raise InvalidRequest(f"target must be a canonical managed full UUID: {exc}") from exc
117
+ except (GenerationError, OSError) as exc:
118
+ raise SessionUnavailable(
119
+ f"CCX worker records are unavailable; check CCX_ROOT: {exc}"
120
+ ) from exc
121
+
122
+
123
+ def _encode_opaque(prefix: str, payload: dict[str, Any]) -> str:
124
+ raw = json.dumps(
125
+ payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False
126
+ ).encode("utf-8")
127
+ return prefix + base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
128
+
129
+
130
+ def _decode_opaque(prefix: str, value: str, *, label: str) -> dict[str, Any]:
131
+ if not isinstance(value, str) or not value.startswith(prefix):
132
+ raise InvalidRequest(f"invalid {label}")
133
+ body = value[len(prefix):]
134
+ padding = "=" * ((4 - len(body) % 4) % 4)
135
+ try:
136
+ decoded = base64.urlsafe_b64decode((body + padding).encode("ascii"))
137
+ payload = json.loads(decoded)
138
+ except (ValueError, UnicodeError, TypeError) as error:
139
+ raise InvalidRequest(f"invalid {label}") from error
140
+ if not isinstance(payload, dict):
141
+ raise InvalidRequest(f"invalid {label}")
142
+ return payload
143
+
144
+
145
+ def _transcript_locator_owned(store, owner, rec: dict[str, Any]) -> str | None:
146
+ """Total, I/O-free token projection of an already-loaded original binding.
147
+
148
+ Inspect is also embedded in *completed* native-effect receipts. An absent
149
+ or contradictory binding must never turn such a receipt into a failure.
150
+ The original source is independently re-proven by conversation() on read.
151
+ """
152
+ try:
153
+ if (not isinstance(rec, dict) or rec.get("workerId") != owner.worker_id
154
+ or rec.get("engine") != owner.engine
155
+ or rec.get("fingerprintVersion") != 2
156
+ or not isinstance(rec.get("createSha256"), str)
157
+ or len(rec["createSha256"]) != 64
158
+ or any(ch not in "0123456789abcdef" for ch in rec["createSha256"])
159
+ or rec.get("terminalOnly")):
160
+ return None
161
+ canonical_worker_id(owner.worker_id)
162
+ native = rec.get("native")
163
+ if not isinstance(native, dict):
164
+ return None
165
+ if owner.engine == "claude":
166
+ canonical_worker_id(native.get("sessionId"))
167
+ if (native.get("nativeState") != "session-bound"
168
+ or not isinstance(rec.get("transcript"), str)
169
+ or not rec["transcript"]):
170
+ return None
171
+ elif owner.engine == "codex":
172
+ if (native.get("nativeState") != "thread-bound"
173
+ or not isinstance(native.get("threadId"), str)
174
+ or not native["threadId"]
175
+ or native.get("threadSource") != store.codex_source(owner.worker)):
176
+ return None
177
+ else:
178
+ return None
179
+ return _encode_opaque(_TRANSCRIPT_LOCATOR_PREFIX, {
180
+ "v": 2, "engine": owner.engine, "worker_id": owner.worker_id,
181
+ "root": hashlib.sha256(os.fsencode(store.root)).hexdigest(),
182
+ "generation": store.generation,
183
+ })
184
+ except Exception:
185
+ return None
186
+
187
+
188
+ def transcript_locator(target: str) -> str | None:
189
+ """Locator for one exact owned full UUID; no legacy short/name aliases."""
190
+ from ccx.constants import CCX_ROOT
191
+ from ccx.store.root import GenerationError, require_ccx_active
192
+ from ccx.store.identity import IdentityError, UnknownWorker
193
+
194
+ try:
195
+ store = require_ccx_active(CCX_ROOT)
196
+ owner = store.resolve(target)
197
+ rec = store.record(owner.worker)
198
+ locator = _transcript_locator_owned(store, owner, rec)
199
+ store.revalidate()
200
+ return locator
201
+ except UnknownWorker as error:
202
+ raise worker_not_found(target) from error
203
+ except IdentityError as error:
204
+ raise InvalidRequest(f"Invalid worker for conversation read; use a worker handle: {error}") from error
205
+ except (GenerationError, OSError) as error:
206
+ raise SessionUnavailable(f"Cannot locate this worker conversation; check the worker: {error}") from error
207
+
208
+
209
+ def recorded_inspector(store, target: str) -> dict[str, Any]:
210
+ from ccx.ops.forking import public_lineage
211
+
212
+ owner = store.resolve(target)
213
+ rec = store.record(owner.worker)
214
+ snapshot = rec.get("backgroundTaskSummary")
215
+ if isinstance(snapshot, dict):
216
+ background = background_task_block(
217
+ recorded_background_facts(snapshot), supported=True)
218
+ else:
219
+ # Historical Claude inspection can still use the explicit diagnostic
220
+ # extractor; the normal recorded owner snapshot is preferred.
221
+ facts = (CcxClient._uuid_background_task_facts_owned(store, owner, rec)
222
+ if owner.engine == "claude" else None)
223
+ background = background_task_block(facts, supported=owner.engine == "claude")
224
+ lifecycle = rec.get("lifecycle")
225
+ lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
226
+ native = rec.get("native")
227
+ native = native if isinstance(native, dict) else {}
228
+ args = rec.get("createArgs")
229
+ args = args if isinstance(args, dict) else {}
230
+ stats = rec.get("stats")
231
+ stats = stats if isinstance(stats, dict) else {}
232
+ return {
233
+ "worker": owner.worker,
234
+ "worker_id": owner.worker_id,
235
+ "engine": owner.engine,
236
+ "short": owner.short, # display only
237
+ "name": rec.get("name"),
238
+ "cwd": rec.get("cwd"),
239
+ "forked_from": public_lineage(rec),
240
+ "state": {
241
+ "state": lifecycle.get("state"),
242
+ "native_state": native.get("nativeState"),
243
+ "source": "recorded",
244
+ },
245
+ "host": "unobserved",
246
+ "loaded": recorded_host_loaded(owner.engine, lifecycle),
247
+ "native": {
248
+ "session_id": native.get("sessionId") if owner.engine == "claude" else None,
249
+ "thread_id": native.get("threadId") if owner.engine == "codex" else None,
250
+ "thread_source": native.get("threadSource") if owner.engine == "codex" else None,
251
+ },
252
+ "pending_input": None,
253
+ "pending_input_observed": False,
254
+ "created_at": rec.get("createdAt"),
255
+ "last_turn_at": rec.get("lastTurnAt"),
256
+ "managed_turns": stats.get("turns"),
257
+ "model": args.get("model"),
258
+ "effort": args.get("effort"),
259
+ "terminal_only": bool(rec.get("terminalOnly")),
260
+ "record_revision": rec.get("recordRevision"),
261
+ "context": None,
262
+ "transcript_locator": _transcript_locator_owned(store, owner, rec),
263
+ "active_turn_ref": None,
264
+ "active_turn_ref_source": "unobserved",
265
+ **background,
266
+ }
267
+
268
+
269
+ def _v1_postnative_inspect(
270
+ info: dict, expected_generation: str | None, *, unproven: str, incomplete: str,
271
+ ) -> dict[str, Any]:
272
+ from ccx import constants
273
+ from ccx.errors import CreatePartialFailure
274
+ from ccx.store.root import require_ccx_active
275
+
276
+ if (info.get("native_ready") is not True
277
+ or info.get("activation_state") != "confirmed"):
278
+ raise CreatePartialFailure(
279
+ unproven, info=info, phase="native-activation",
280
+ )
281
+ try:
282
+ store = require_ccx_active(
283
+ constants.CCX_ROOT, expected_generation=expected_generation,
284
+ )
285
+ inspected = recorded_inspector(store, info["worker"])
286
+ store.revalidate()
287
+ return {
288
+ **inspected, "worker_id": info["worker_id"],
289
+ "created": info["created"], "reconciled": info["reconciled"],
290
+ }
291
+ except Exception as error:
292
+ raise CreatePartialFailure(
293
+ incomplete, info=info, phase="inspect", cause=error,
294
+ ) from error
295
+
296
+
297
+ _BACKGROUND_BLOCK_MAX_BYTES = 64 * 1024
298
+
299
+
300
+ _BACKGROUND_TEXT_MAX_CHARS = 1024
301
+
302
+
303
+ _BACKGROUND_TEXT_FIELDS = (
304
+ "command", "description", "output_file", "summary", "last_event",
305
+ )
306
+
307
+
308
+ _BACKGROUND_FACT_KEYS = (
309
+ "background_tasks",
310
+ "background_task_count",
311
+ "running_background_task_count",
312
+ "open_background_task_count",
313
+ "active_background_task_count",
314
+ "stale_background_task_count",
315
+ "background_tasks_truncated",
316
+ "background_task_limit",
317
+ "background_task_hint",
318
+ "background_task_summary",
319
+ )
320
+
321
+
322
+ def _background_block_size(block: dict[str, Any]) -> int:
323
+ return len(json.dumps(
324
+ block, ensure_ascii=False, separators=(",", ":"), sort_keys=True,
325
+ default=str,
326
+ ).encode("utf-8"))
327
+
328
+
329
+ def recorded_background_facts(snapshot: dict) -> dict[str, Any]:
330
+ """Facts from the owner's recorded summary: its bounded items are the list.
331
+
332
+ The recorded items are the compact owner view (id, kind, status, label...),
333
+ not the detailed transcript inventory that ``CcxClient.background_tasks()``
334
+ returns; ``background_tasks_truncated`` reflects the summary's own bound.
335
+ """
336
+ items = snapshot.get("items")
337
+ items = [dict(item) for item in items if isinstance(item, dict)] if isinstance(items, list) else []
338
+ return {
339
+ "background_task_summary": snapshot,
340
+ "background_tasks": items,
341
+ "background_tasks_truncated": bool(snapshot.get("items_truncated")),
342
+ "running_background_task_count": snapshot.get("running_count",
343
+ snapshot.get("runningCount")),
344
+ }
345
+
346
+
347
+ def background_task_block(
348
+ facts: dict[str, Any] | None, *, supported: bool
349
+ ) -> dict[str, Any]:
350
+ """Bound one ``_background_task_facts`` projection for local protocol.
351
+
352
+ Unsupported engines get only ``background_tasks_supported=false``: absent
353
+ counts are not an observed empty inventory. Identities and counts are kept
354
+ exactly; ``background_task_details_truncated`` reports text clipping or
355
+ detail omission, while ``background_tasks_truncated`` keeps meaning "the
356
+ list is not the whole inventory".
357
+ """
358
+ if not supported:
359
+ return {"background_tasks_supported": False}
360
+ facts = facts or {}
361
+ block: dict[str, Any] = {"background_tasks_supported": True}
362
+ for key in _BACKGROUND_FACT_KEYS:
363
+ if key in facts:
364
+ block[key] = facts[key]
365
+ details_truncated = False
366
+ tasks = []
367
+ for task in block.get("background_tasks") or []:
368
+ if not isinstance(task, dict):
369
+ continue
370
+ task = dict(task)
371
+ for field_name in _BACKGROUND_TEXT_FIELDS:
372
+ value = task.get(field_name)
373
+ if isinstance(value, str) and len(value) > _BACKGROUND_TEXT_MAX_CHARS:
374
+ task[field_name] = value[:_BACKGROUND_TEXT_MAX_CHARS]
375
+ details_truncated = True
376
+ tasks.append(task)
377
+ block["background_tasks"] = tasks
378
+ hint = block.get("background_task_hint")
379
+ if isinstance(hint, str) and len(hint) > _BACKGROUND_TEXT_MAX_CHARS:
380
+ block["background_task_hint"] = hint[:_BACKGROUND_TEXT_MAX_CHARS]
381
+ details_truncated = True
382
+ block["background_task_details_truncated"] = details_truncated
383
+ # Over budget: drop text details oldest-first, then oldest entries.
384
+ for task in tasks:
385
+ if _background_block_size(block) <= _BACKGROUND_BLOCK_MAX_BYTES:
386
+ break
387
+ for field_name in _BACKGROUND_TEXT_FIELDS:
388
+ if task.get(field_name) is not None:
389
+ task[field_name] = None
390
+ block["background_task_details_truncated"] = True
391
+ while tasks and _background_block_size(block) > _BACKGROUND_BLOCK_MAX_BYTES:
392
+ tasks.pop(0)
393
+ block["background_tasks_truncated"] = True
394
+ return block
395
+
396
+
397
+ MAX_RESPONSE_ID = 128