netizen-cli 0.10.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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1151 @@
1
+ """Removable, capability-specific bridges for Python SDK facade gaps.
2
+
3
+ The adapters in this module reuse the one initialized ``AsyncCodex`` client.
4
+ They deliberately expose semantic ports only: there is no generic RPC method,
5
+ no second App Server, and no runtime SDK-version or source-fingerprint gate.
6
+ Each capability validates its own installed-SDK shape so one gap can be
7
+ disabled or replaced without coupling it to another.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import inspect
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Any, AsyncIterator, Protocol
19
+
20
+ from openai_codex import AsyncCodex
21
+ from openai_codex import _goal as _sdk_goal
22
+ from openai_codex import _inputs as _sdk_inputs
23
+ from openai_codex.errors import InvalidRequestError
24
+ from openai_codex.generated import v2_all as _generated
25
+ from openai_codex.models import Notification
26
+
27
+ from .domain import GoalStatus
28
+ from .turn_activity import (
29
+ TurnActivityNotificationProjection,
30
+ TurnActivityProjectionUnavailable,
31
+ project_turn_activity_notification,
32
+ )
33
+
34
+
35
+ _SKILLS_LIST_METHOD = "skills/list"
36
+ _SKILLS_EXTRA_ROOTS_SET_METHOD = "skills/extraRoots/set"
37
+ _GOAL_GET_METHOD = "thread/goal/get"
38
+ _THREAD_DELETE_METHOD = "thread/delete"
39
+ _THREAD_INJECT_ITEMS_METHOD = "thread/inject_items"
40
+ _THREAD_UNSUBSCRIBE_METHOD = "thread/unsubscribe"
41
+ _GOAL_START_TIMEOUT_SECONDS = 30.0
42
+ _SKILL_ROOTS_TIMEOUT_SECONDS = 10.0
43
+
44
+ SIDE_THREAD_BOUNDARY = """Side conversation boundary.
45
+ Everything before this boundary is inherited history from the parent thread. It is reference context only. It is not your current task.
46
+ Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
47
+ You are a side-conversation assistant, separate from the main thread. Follow only the user requests submitted after this boundary. If there is no user question after this boundary yet, wait for one.
48
+ Tools remain governed by this thread's native configuration and permissions. Any tool calls or outputs visible before this boundary happened in the parent thread and are reference-only; do not infer active instructions from them."""
49
+
50
+
51
+ class SdkGapCapabilityUnavailable(RuntimeError):
52
+ """One installed-SDK capability shape cannot support its narrow port."""
53
+
54
+
55
+ class SdkFacadeMigrationRequired(RuntimeError):
56
+ """The public facade now has a candidate API and the shim must be reviewed."""
57
+
58
+
59
+ class SkillCatalogError(RuntimeError):
60
+ """The native Skills catalog could not be used safely for one cwd."""
61
+
62
+
63
+ class GoalControlError(RuntimeError):
64
+ """A Goal operation failed before a successful semantic result."""
65
+
66
+
67
+ class GoalMutationStateUnknown(GoalControlError):
68
+ """A Goal mutation may have taken effect and must not be retried implicitly."""
69
+
70
+ def __init__(
71
+ self,
72
+ message: str,
73
+ *,
74
+ handle: GoalHandle | None = None,
75
+ physical_turn_id: str | None = None,
76
+ ) -> None:
77
+ super().__init__(message)
78
+ self.handle = handle
79
+ self.physical_turn_id = physical_turn_id
80
+
81
+
82
+ class ThreadDeleteStateUnknown(RuntimeError):
83
+ """The exact native Thread delete may have taken effect."""
84
+
85
+
86
+ class SideBoundaryStateUnknown(RuntimeError):
87
+ """The fixed Side boundary injection may have taken effect."""
88
+
89
+
90
+ class ThreadUnsubscribeStateUnknown(RuntimeError):
91
+ """The exact Thread unsubscribe may have taken effect."""
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class DiscoveredSkill:
96
+ name: str
97
+ path: str
98
+ description: str
99
+ scope: str
100
+ enabled: bool
101
+ display_name: str | None = None
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class SkillCatalogSnapshot:
106
+ cwd: Path
107
+ skills: tuple[DiscoveredSkill, ...]
108
+ errors: tuple[str, ...]
109
+
110
+
111
+ class SkillCatalog(Protocol):
112
+ async def list(
113
+ self,
114
+ cwd: Path,
115
+ *,
116
+ force_reload: bool = True,
117
+ ) -> SkillCatalogSnapshot: ...
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class GoalSnapshot:
122
+ thread_id: str
123
+ objective: str
124
+ status: GoalStatus
125
+ token_budget: int | None
126
+ tokens_used: int
127
+ time_used_seconds: int
128
+ created_at: int
129
+ updated_at: int
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class GoalStreamTerminal:
134
+ logical_turn_id: str
135
+ final_physical_turn_id: str
136
+ turn_status: str
137
+ turn_diff: str | None = None
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class GoalPauseAck:
142
+ goal: GoalSnapshot
143
+ physical_turn_id: str | None
144
+ interrupt_acknowledged: bool
145
+
146
+
147
+ class GoalHandle(Protocol):
148
+ @property
149
+ def id(self) -> str | None: ...
150
+
151
+ @property
152
+ def thread_id(self) -> str: ...
153
+
154
+ def current_physical_turn_id(self) -> str | None: ...
155
+
156
+ async def steer(self, input: Any, *, expected_turn_id: str) -> object: ...
157
+
158
+ async def wait_terminal(
159
+ self,
160
+ activity_sink: Callable[
161
+ [TurnActivityNotificationProjection | None], None
162
+ ]
163
+ | None = None,
164
+ ) -> GoalStreamTerminal: ...
165
+
166
+ async def pause(self) -> GoalPauseAck: ...
167
+
168
+ async def aclose(self) -> None: ...
169
+
170
+
171
+ class GoalControl(Protocol):
172
+ async def get(self, thread_id: str) -> GoalSnapshot | None: ...
173
+
174
+ async def start(self, thread_id: str, objective: str) -> GoalHandle: ...
175
+
176
+ async def resume(self, thread_id: str) -> GoalHandle: ...
177
+
178
+ async def clear(self, thread_id: str) -> bool: ...
179
+
180
+
181
+ class ThreadDeleteControl(Protocol):
182
+ async def delete(self, thread_id: str) -> None: ...
183
+
184
+
185
+ class ThreadUnsubscribeStatus(str, Enum):
186
+ NOT_LOADED = "notLoaded"
187
+ NOT_SUBSCRIBED = "notSubscribed"
188
+ UNSUBSCRIBED = "unsubscribed"
189
+
190
+
191
+ class ThreadSubscriptionControl(Protocol):
192
+ async def unsubscribe(self, thread_id: str) -> ThreadUnsubscribeStatus: ...
193
+
194
+
195
+ class SideBoundaryControl(Protocol):
196
+ async def inject_boundary(self, thread_id: str) -> None: ...
197
+
198
+
199
+ class AppServerSkillCatalog:
200
+ """Fixed ``skills/list`` bridge for the installed SDK's generated schema."""
201
+
202
+ __slots__ = ("_client", "_response_model")
203
+
204
+ def __init__(self, codex: AsyncCodex) -> None:
205
+ client = _initialized_client(codex, capability="skills")
206
+ response_model = _generated_type(
207
+ "SkillsListResponse",
208
+ capability="skills",
209
+ )
210
+ params_model = _generated_type("SkillsListParams", capability="skills")
211
+ _require_model_fields(
212
+ params_model,
213
+ capability="skills",
214
+ aliases={"cwds": "cwds", "force_reload": "forceReload"},
215
+ )
216
+ _require_model_fields(
217
+ response_model,
218
+ capability="skills",
219
+ aliases={"data": "data"},
220
+ )
221
+ self._client = client
222
+ self._response_model = response_model
223
+
224
+ async def list(
225
+ self,
226
+ cwd: Path,
227
+ *,
228
+ force_reload: bool = True,
229
+ ) -> SkillCatalogSnapshot:
230
+ canonical = cwd.resolve()
231
+ response = await self._client.request(
232
+ _SKILLS_LIST_METHOD,
233
+ {
234
+ "cwds": [str(canonical)],
235
+ "forceReload": force_reload,
236
+ },
237
+ response_model=self._response_model,
238
+ )
239
+ entries = tuple(getattr(response, "data", ()))
240
+ matching = tuple(
241
+ entry
242
+ for entry in entries
243
+ if Path(getattr(entry, "cwd", "")).resolve() == canonical
244
+ )
245
+ if len(entries) != 1 or len(matching) != 1:
246
+ raise SkillCatalogError(
247
+ "Codex Skills 目录没有返回唯一的当前 Project 条目。"
248
+ )
249
+ entry = matching[0]
250
+ errors = tuple(
251
+ f"{getattr(item, 'path', '')}: {getattr(item, 'message', '')}".strip(
252
+ ": "
253
+ )
254
+ for item in getattr(entry, "errors", ())
255
+ )
256
+ skills: list[DiscoveredSkill] = []
257
+ for item in getattr(entry, "skills", ()):
258
+ path_value = getattr(getattr(item, "path", None), "root", None)
259
+ if not isinstance(path_value, str) or not Path(path_value).is_absolute():
260
+ raise SkillCatalogError("Codex 返回了非绝对路径的 Skill,已拒绝使用。")
261
+ scope_value = getattr(getattr(item, "scope", None), "value", None)
262
+ if not isinstance(scope_value, str):
263
+ raise SkillCatalogError("Codex 返回了未知的 Skill scope。")
264
+ interface = getattr(item, "interface", None)
265
+ display_name = getattr(interface, "display_name", None)
266
+ skills.append(
267
+ DiscoveredSkill(
268
+ name=_trimmed_string(getattr(item, "name", None), "Skill name"),
269
+ path=path_value,
270
+ description=_trimmed_string(
271
+ getattr(item, "description", None),
272
+ "Skill description",
273
+ ),
274
+ scope=scope_value,
275
+ enabled=getattr(item, "enabled", None) is True,
276
+ display_name=(
277
+ display_name
278
+ if isinstance(display_name, str) and display_name.strip()
279
+ else None
280
+ ),
281
+ )
282
+ )
283
+ return SkillCatalogSnapshot(canonical, tuple(skills), errors)
284
+
285
+
286
+ class AppServerSkillRoots:
287
+ """Set only this initialized App Server's non-persisted extra Skill roots.
288
+
289
+ ADR 0074 permits this startup-only fixed method. A failure must prevent
290
+ service readiness; it must never fall back to shared global Skill copies.
291
+ """
292
+
293
+ __slots__ = ("_client", "_params_model", "_response_model")
294
+
295
+ def __init__(self, codex: AsyncCodex) -> None:
296
+ capability = "skill-roots"
297
+ self._client = _initialized_client(codex, capability=capability)
298
+ self._params_model = _generated_type(
299
+ "SkillsExtraRootsSetParams", capability=capability
300
+ )
301
+ self._response_model = _generated_type(
302
+ "SkillsExtraRootsSetResponse", capability=capability
303
+ )
304
+ _require_model_fields(
305
+ self._params_model,
306
+ capability=capability,
307
+ aliases={"extra_roots": "extraRoots"},
308
+ )
309
+ _require_model_fields(
310
+ self._response_model, capability=capability, aliases={}
311
+ )
312
+
313
+ async def set_roots(self, roots: tuple[Path, ...]) -> None:
314
+ canonical: list[str] = []
315
+ for root in roots:
316
+ if not root.is_absolute():
317
+ raise SkillCatalogError("内置 Skill 根必须是绝对目录。")
318
+ path = root.resolve(strict=True)
319
+ if not path.is_dir():
320
+ raise SkillCatalogError(f"内置 Skill 根不是目录:{path}")
321
+ if str(path) not in canonical:
322
+ canonical.append(str(path))
323
+ params = self._params_model(extraRoots=canonical)
324
+ async with asyncio.timeout(_SKILL_ROOTS_TIMEOUT_SECONDS):
325
+ await self._client.request(
326
+ _SKILLS_EXTRA_ROOTS_SET_METHOD,
327
+ params.model_dump(by_alias=True),
328
+ response_model=self._response_model,
329
+ )
330
+
331
+
332
+ class AppServerThreadDeleteControl:
333
+ """Narrow fixed-method bridge for the missing public delete facade.
334
+
335
+ ADR 0037 permits this capability-specific adapter in production. The
336
+ facade sentinel still forces an explicit migration as soon as the public
337
+ SDK exposes native Thread delete.
338
+ """
339
+
340
+ __slots__ = ("_client", "_response_model")
341
+
342
+ def __init__(self, codex: AsyncCodex) -> None:
343
+ client = _initialized_client(codex, capability="thread-delete")
344
+ params_model = _generated_type(
345
+ "ThreadDeleteParams",
346
+ capability="thread-delete",
347
+ )
348
+ response_model = _generated_type(
349
+ "ThreadDeleteResponse",
350
+ capability="thread-delete",
351
+ )
352
+ _require_model_fields(
353
+ params_model,
354
+ capability="thread-delete",
355
+ aliases={"thread_id": "threadId"},
356
+ )
357
+ _require_model_fields(
358
+ response_model,
359
+ capability="thread-delete",
360
+ aliases={},
361
+ )
362
+ self._client = client
363
+ self._response_model = response_model
364
+
365
+ async def delete(self, thread_id: str) -> None:
366
+ _validate_thread_id(thread_id)
367
+ try:
368
+ await self._client.request(
369
+ _THREAD_DELETE_METHOD,
370
+ {"threadId": thread_id},
371
+ response_model=self._response_model,
372
+ )
373
+ except asyncio.CancelledError:
374
+ raise
375
+ except Exception as error:
376
+ raise ThreadDeleteStateUnknown(
377
+ "Codex Thread 删除结果未确认;不能自动重试。"
378
+ ) from error
379
+
380
+
381
+ class AppServerSideBoundaryControl:
382
+ """Fixed bridge for the App Server method that installs a Side boundary."""
383
+
384
+ __slots__ = ("_client", "_inject_response_model")
385
+
386
+ def __init__(self, codex: AsyncCodex) -> None:
387
+ client = _initialized_client(codex, capability="side-boundary")
388
+ inject_params_model = _generated_type(
389
+ "ThreadInjectItemsParams",
390
+ capability="side-boundary",
391
+ )
392
+ inject_response_model = _generated_type(
393
+ "ThreadInjectItemsResponse",
394
+ capability="side-boundary",
395
+ )
396
+ _require_model_fields(
397
+ inject_params_model,
398
+ capability="side-boundary",
399
+ aliases={"items": "items", "thread_id": "threadId"},
400
+ )
401
+ _require_model_fields(
402
+ inject_response_model,
403
+ capability="side-boundary",
404
+ aliases={},
405
+ )
406
+ self._client = client
407
+ self._inject_response_model = inject_response_model
408
+
409
+ async def inject_boundary(self, thread_id: str) -> None:
410
+ _validate_thread_id(thread_id)
411
+ try:
412
+ await self._client.request(
413
+ _THREAD_INJECT_ITEMS_METHOD,
414
+ {
415
+ "threadId": thread_id,
416
+ "items": [
417
+ {
418
+ "type": "message",
419
+ "role": "user",
420
+ "content": [
421
+ {
422
+ "type": "input_text",
423
+ "text": SIDE_THREAD_BOUNDARY,
424
+ }
425
+ ],
426
+ }
427
+ ],
428
+ },
429
+ response_model=self._inject_response_model,
430
+ )
431
+ except asyncio.CancelledError:
432
+ raise
433
+ except Exception as error:
434
+ raise SideBoundaryStateUnknown(
435
+ "Side 边界注入结果未确认;不能自动重试。"
436
+ ) from error
437
+
438
+
439
+ class AppServerThreadSubscriptionControl:
440
+ """Fixed bridge for removing this connection's exact Thread subscription."""
441
+
442
+ __slots__ = ("_client", "_unsubscribe_response_model")
443
+
444
+ def __init__(self, codex: AsyncCodex) -> None:
445
+ client = _initialized_client(codex, capability="thread-subscription")
446
+ unsubscribe_params_model = _generated_type(
447
+ "ThreadUnsubscribeParams",
448
+ capability="thread-subscription",
449
+ )
450
+ unsubscribe_response_model = _generated_type(
451
+ "ThreadUnsubscribeResponse",
452
+ capability="thread-subscription",
453
+ )
454
+ unsubscribe_status_model = _generated_type(
455
+ "ThreadUnsubscribeStatus",
456
+ capability="thread-subscription",
457
+ )
458
+ _require_model_fields(
459
+ unsubscribe_params_model,
460
+ capability="thread-subscription",
461
+ aliases={"thread_id": "threadId"},
462
+ )
463
+ _require_model_fields(
464
+ unsubscribe_response_model,
465
+ capability="thread-subscription",
466
+ aliases={"status": "status"},
467
+ )
468
+ if not issubclass(unsubscribe_status_model, Enum):
469
+ raise SdkGapCapabilityUnavailable(
470
+ "thread-subscription unsubscribe status enum shape changed"
471
+ )
472
+ try:
473
+ status_values = {member.value for member in unsubscribe_status_model}
474
+ except (AttributeError, TypeError) as error:
475
+ raise SdkGapCapabilityUnavailable(
476
+ "thread-subscription unsubscribe status enum shape changed"
477
+ ) from error
478
+ if status_values != {status.value for status in ThreadUnsubscribeStatus}:
479
+ raise SdkGapCapabilityUnavailable(
480
+ "thread-subscription unsubscribe status enum shape changed"
481
+ )
482
+ self._client = client
483
+ self._unsubscribe_response_model = unsubscribe_response_model
484
+
485
+ async def unsubscribe(self, thread_id: str) -> ThreadUnsubscribeStatus:
486
+ _validate_thread_id(thread_id)
487
+ try:
488
+ response = await self._client.request(
489
+ _THREAD_UNSUBSCRIBE_METHOD,
490
+ {"threadId": thread_id},
491
+ response_model=self._unsubscribe_response_model,
492
+ )
493
+ except asyncio.CancelledError:
494
+ raise
495
+ except Exception as error:
496
+ raise ThreadUnsubscribeStateUnknown(
497
+ "Thread 取消订阅结果未确认;不能自动重试。"
498
+ ) from error
499
+ raw_status = getattr(getattr(response, "status", None), "value", None)
500
+ try:
501
+ return ThreadUnsubscribeStatus(raw_status)
502
+ except (TypeError, ValueError) as error:
503
+ raise ThreadUnsubscribeStateUnknown(
504
+ "Thread 取消订阅响应形状无法确认。"
505
+ ) from error
506
+
507
+
508
+ class AppServerGoalControl:
509
+ """Fixed Goal bridge that delegates continuation routing to the SDK."""
510
+
511
+ __slots__ = (
512
+ "_client",
513
+ "_get_response_model",
514
+ "_status_model",
515
+ "_stream_type",
516
+ )
517
+
518
+ def __init__(self, codex: AsyncCodex) -> None:
519
+ client = _initialized_client(codex, capability="goal")
520
+ for method_name in (
521
+ "request",
522
+ "start_goal_operation",
523
+ "register_goal_operation",
524
+ "unregister_goal_operation",
525
+ "next_goal_notification",
526
+ "cancel_goal_operation",
527
+ "thread_goal_set",
528
+ "thread_goal_clear",
529
+ "pause_goal",
530
+ "turn_interrupt",
531
+ "turn_steer",
532
+ ):
533
+ if not callable(getattr(client, method_name, None)):
534
+ raise SdkGapCapabilityUnavailable(
535
+ f"goal SDK shape missing callable {method_name}"
536
+ )
537
+ if set(inspect.signature(client.turn_steer).parameters) != {
538
+ "thread_id", "expected_turn_id", "input_items"
539
+ }:
540
+ raise SdkGapCapabilityUnavailable("goal steer SDK signature changed")
541
+ for function_name in ("_normalize_run_input", "_to_wire_input"):
542
+ function = getattr(_sdk_inputs, function_name, None)
543
+ if not callable(function) or set(inspect.signature(function).parameters) != {
544
+ "input"
545
+ }:
546
+ raise SdkGapCapabilityUnavailable(
547
+ f"goal input SDK shape changed for {function_name}"
548
+ )
549
+ steer_response_model = _generated_type("TurnSteerResponse", capability="goal")
550
+ _require_model_fields(
551
+ steer_response_model,
552
+ capability="goal",
553
+ aliases={"turn_id": "turnId"},
554
+ )
555
+ get_response_model = _generated_type(
556
+ "ThreadGoalGetResponse",
557
+ capability="goal",
558
+ )
559
+ get_params_model = _generated_type(
560
+ "ThreadGoalGetParams",
561
+ capability="goal",
562
+ )
563
+ status_model = _generated_type("ThreadGoalStatus", capability="goal")
564
+ diff_notification_model = _generated_type(
565
+ "TurnDiffUpdatedNotification",
566
+ capability="goal",
567
+ )
568
+ _require_model_fields(
569
+ get_params_model,
570
+ capability="goal",
571
+ aliases={"thread_id": "threadId"},
572
+ )
573
+ _require_model_fields(
574
+ get_response_model,
575
+ capability="goal",
576
+ aliases={"goal": "goal"},
577
+ )
578
+ _require_model_fields(
579
+ diff_notification_model,
580
+ capability="goal",
581
+ aliases={
582
+ "diff": "diff",
583
+ "thread_id": "threadId",
584
+ "turn_id": "turnId",
585
+ },
586
+ )
587
+ if {member.value for member in status_model} != {
588
+ status.value for status in GoalStatus
589
+ }:
590
+ raise SdkGapCapabilityUnavailable("goal status enum shape changed")
591
+ stream_type = getattr(_sdk_goal, "_AsyncGoalNotificationStream", None)
592
+ state_type = getattr(_sdk_goal, "_GoalOperationState", None)
593
+ if not isinstance(stream_type, type) or not isinstance(state_type, type):
594
+ raise SdkGapCapabilityUnavailable("goal stream ownership shape changed")
595
+ required_state_parameters = {
596
+ "thread_id",
597
+ "logical_turn_id",
598
+ "current_turn_id",
599
+ "completed_turn",
600
+ }
601
+ if not required_state_parameters.issubset(
602
+ inspect.signature(state_type).parameters
603
+ ):
604
+ raise SdkGapCapabilityUnavailable("goal state data shape changed")
605
+ expected_stream_parameters = {
606
+ "state",
607
+ "next_notification",
608
+ "unregister",
609
+ "cancel_goal",
610
+ "_pending",
611
+ "_closed",
612
+ }
613
+ if set(inspect.signature(stream_type).parameters) != expected_stream_parameters:
614
+ raise SdkGapCapabilityUnavailable("goal stream constructor shape changed")
615
+ for state_method in ("current_turn", "wait_for_start"):
616
+ if not callable(getattr(state_type, state_method, None)):
617
+ raise SdkGapCapabilityUnavailable(
618
+ f"goal state shape missing {state_method}"
619
+ )
620
+ self._client = client
621
+ self._get_response_model = get_response_model
622
+ self._status_model = status_model
623
+ self._stream_type = stream_type
624
+
625
+ async def get(self, thread_id: str) -> GoalSnapshot | None:
626
+ _validate_thread_id(thread_id)
627
+ response = await self._client.request(
628
+ _GOAL_GET_METHOD,
629
+ {"threadId": thread_id},
630
+ response_model=self._get_response_model,
631
+ )
632
+ goal = getattr(response, "goal", None)
633
+ if goal is None:
634
+ return None
635
+ snapshot = _goal_snapshot(goal)
636
+ if snapshot.thread_id != thread_id:
637
+ raise GoalControlError(
638
+ "Codex Goal 响应与请求的原生 Thread 不一致。"
639
+ )
640
+ return snapshot
641
+
642
+ async def start(self, thread_id: str, objective: str) -> GoalHandle:
643
+ _validate_thread_id(thread_id)
644
+ objective = _validate_objective(objective)
645
+ try:
646
+ state, logical_turn_id = await self._client.start_goal_operation(
647
+ thread_id,
648
+ objective,
649
+ )
650
+ except asyncio.CancelledError:
651
+ raise
652
+ except Exception as error:
653
+ raise GoalMutationStateUnknown(
654
+ "Codex Goal 启动结果未确认;不能自动重试。"
655
+ ) from error
656
+ return self._handle(state, logical_turn_id)
657
+
658
+ async def resume(self, thread_id: str) -> GoalHandle:
659
+ _validate_thread_id(thread_id)
660
+ try:
661
+ state = self._client.register_goal_operation(thread_id)
662
+ except Exception as error:
663
+ raise GoalControlError("无法为既有 Goal 注册通知路由。") from error
664
+ handle = self._handle(state, None)
665
+ mutation_attempted = False
666
+ try:
667
+ mutation_attempted = True
668
+ response = await self._client.thread_goal_set(
669
+ thread_id,
670
+ status=self._status_model.active,
671
+ )
672
+ snapshot = _goal_snapshot(getattr(response, "goal", None))
673
+ if snapshot.thread_id != thread_id or snapshot.status is not GoalStatus.ACTIVE:
674
+ raise RuntimeError("goal resume acknowledgement did not match request")
675
+ logical_turn_id = await asyncio.to_thread(
676
+ state.wait_for_start,
677
+ _GOAL_START_TIMEOUT_SECONDS,
678
+ )
679
+ if not isinstance(logical_turn_id, str) or not logical_turn_id:
680
+ raise TimeoutError("timed out waiting for resumed goal turn")
681
+ handle._bind_logical_turn(logical_turn_id)
682
+ return handle
683
+ except asyncio.CancelledError as error:
684
+ if mutation_attempted:
685
+ # Cancellation must continue to cancel the caller, but the
686
+ # route was registered before the mutation and is now owned by
687
+ # Runtime until transport teardown. Attach the opaque handle
688
+ # so Runtime can retain that ownership without converting
689
+ # cancellation into an ordinary exception.
690
+ error.goal_handle = handle
691
+ raise
692
+ await handle.aclose()
693
+ raise
694
+ except Exception as error:
695
+ if mutation_attempted:
696
+ raise GoalMutationStateUnknown(
697
+ "Codex Goal 恢复结果未确认;不能自动重试。",
698
+ handle=handle,
699
+ ) from error
700
+ await handle.aclose()
701
+ raise GoalControlError("Codex Goal 恢复前置检查失败。") from error
702
+
703
+ async def clear(self, thread_id: str) -> bool:
704
+ _validate_thread_id(thread_id)
705
+ try:
706
+ response = await self._client.thread_goal_clear(thread_id)
707
+ except asyncio.CancelledError:
708
+ raise
709
+ except Exception as error:
710
+ raise GoalMutationStateUnknown(
711
+ "Codex Goal 清除结果未确认;不能自动重试。"
712
+ ) from error
713
+ cleared = getattr(response, "cleared", None)
714
+ if not isinstance(cleared, bool):
715
+ raise GoalMutationStateUnknown("Codex Goal 清除响应形状无法确认。")
716
+ return cleared
717
+
718
+ def _handle(
719
+ self,
720
+ state: Any,
721
+ logical_turn_id: str | None,
722
+ ) -> _AppServerGoalHandle:
723
+ activity_tap = _GoalActivityTap(state.thread_id)
724
+ stream = self._stream_type(
725
+ state=state,
726
+ next_notification=lambda: activity_tap.next_notification(
727
+ self._client,
728
+ state,
729
+ ),
730
+ unregister=lambda: self._client.unregister_goal_operation(state),
731
+ cancel_goal=lambda: self._client.cancel_goal_operation(state),
732
+ )
733
+ return _AppServerGoalHandle(
734
+ client=self._client,
735
+ status_model=self._status_model,
736
+ state=state,
737
+ stream=stream,
738
+ logical_turn_id=logical_turn_id,
739
+ activity_tap=activity_tap,
740
+ )
741
+
742
+
743
+ class _GoalActivityTap:
744
+ """Project activity and final-Turn diff in the one Goal consumer."""
745
+
746
+ __slots__ = (
747
+ "_thread_id",
748
+ "_turn_id",
749
+ "_completed_turn_id",
750
+ "_turn_diff",
751
+ "_diff_enabled",
752
+ "_sink",
753
+ )
754
+
755
+ def __init__(self, thread_id: str) -> None:
756
+ self._thread_id = thread_id
757
+ self._turn_id: str | None = None
758
+ self._completed_turn_id: str | None = None
759
+ self._turn_diff: str | None = None
760
+ self._diff_enabled = True
761
+ self._sink: Callable[
762
+ [TurnActivityNotificationProjection | None], None
763
+ ] | None = None
764
+
765
+ def bind(
766
+ self,
767
+ sink: Callable[[TurnActivityNotificationProjection | None], None] | None,
768
+ ) -> None:
769
+ self._sink = sink
770
+
771
+ async def next_notification(self, client: Any, state: Any) -> Any:
772
+ notification = await client.next_goal_notification(state)
773
+ self._capture_turn_diff(notification)
774
+ sink = self._sink
775
+ if sink is None:
776
+ return notification
777
+ try:
778
+ projection = project_turn_activity_notification(
779
+ notification,
780
+ expected_thread_id=self._thread_id,
781
+ expected_turn_id=self._turn_id,
782
+ )
783
+ except TurnActivityProjectionUnavailable:
784
+ self._sink = None
785
+ try:
786
+ sink(None)
787
+ except Exception:
788
+ pass
789
+ return notification
790
+ if projection.turn_started:
791
+ self._turn_id = projection.turn_id
792
+ if not (
793
+ projection.turn_started
794
+ or projection.turn_completed
795
+ or projection.plan_updated
796
+ or projection.event is not None
797
+ or projection.question is not None
798
+ ):
799
+ return notification
800
+ try:
801
+ sink(projection)
802
+ except Exception:
803
+ # Activity is display-only. A projection consumer can disable
804
+ # itself but must never interrupt the one Goal notification stream.
805
+ self._sink = None
806
+ return notification
807
+
808
+ def final_turn_diff(self, physical_turn_id: str) -> str | None:
809
+ if (
810
+ self._diff_enabled
811
+ and self._turn_id == physical_turn_id
812
+ and self._completed_turn_id == physical_turn_id
813
+ ):
814
+ return self._turn_diff
815
+ return None
816
+
817
+ def _capture_turn_diff(self, notification: Any) -> None:
818
+ if not self._diff_enabled:
819
+ return
820
+ if type(notification) is not Notification:
821
+ self._disable_diff()
822
+ return
823
+ payload = notification.payload
824
+ if notification.method == "turn/started":
825
+ if type(payload) is not _generated.TurnStartedNotification:
826
+ self._disable_diff()
827
+ return
828
+ turn_id = getattr(payload.turn, "id", None)
829
+ if (
830
+ payload.thread_id != self._thread_id
831
+ or not isinstance(turn_id, str)
832
+ or not turn_id
833
+ ):
834
+ self._disable_diff()
835
+ return
836
+ self._turn_id = turn_id
837
+ self._completed_turn_id = None
838
+ self._turn_diff = None
839
+ return
840
+ if notification.method == "turn/diff/updated":
841
+ if type(payload) is not _generated.TurnDiffUpdatedNotification:
842
+ self._disable_diff()
843
+ return
844
+ if payload.thread_id != self._thread_id:
845
+ self._disable_diff()
846
+ return
847
+ if self._turn_id is None:
848
+ self._disable_diff()
849
+ return
850
+ if payload.turn_id == self._turn_id:
851
+ self._turn_diff = payload.diff
852
+ return
853
+ if notification.method == "turn/completed":
854
+ if type(payload) is not _generated.TurnCompletedNotification:
855
+ self._disable_diff()
856
+ return
857
+ turn_id = getattr(payload.turn, "id", None)
858
+ if payload.thread_id != self._thread_id:
859
+ self._disable_diff()
860
+ return
861
+ if turn_id == self._turn_id:
862
+ self._completed_turn_id = turn_id
863
+
864
+ def _disable_diff(self) -> None:
865
+ self._diff_enabled = False
866
+ self._turn_diff = None
867
+ self._completed_turn_id = None
868
+
869
+
870
+ class _AppServerGoalHandle:
871
+ __slots__ = (
872
+ "_client",
873
+ "_status_model",
874
+ "_state",
875
+ "_stream",
876
+ "_logical_turn_id",
877
+ "_activity_tap",
878
+ )
879
+
880
+ def __init__(
881
+ self,
882
+ *,
883
+ client: Any,
884
+ status_model: Any,
885
+ state: Any,
886
+ stream: AsyncIterator[Any],
887
+ logical_turn_id: str | None,
888
+ activity_tap: _GoalActivityTap,
889
+ ) -> None:
890
+ self._client = client
891
+ self._status_model = status_model
892
+ self._state = state
893
+ self._stream = stream
894
+ self._logical_turn_id = logical_turn_id
895
+ self._activity_tap = activity_tap
896
+
897
+ @property
898
+ def id(self) -> str | None:
899
+ return self._logical_turn_id
900
+
901
+ @property
902
+ def thread_id(self) -> str:
903
+ return self._state.thread_id
904
+
905
+ def _bind_logical_turn(self, logical_turn_id: str) -> None:
906
+ if self._logical_turn_id is not None:
907
+ raise RuntimeError("Goal handle is already bound")
908
+ self._logical_turn_id = logical_turn_id
909
+
910
+ def current_physical_turn_id(self) -> str | None:
911
+ value = self._state.current_turn()
912
+ return value if isinstance(value, str) and value else None
913
+
914
+ async def steer(self, input: Any, *, expected_turn_id: str) -> object:
915
+ """Steer only the captured physical Turn, without acquiring a stream."""
916
+
917
+ _trimmed_string(expected_turn_id, "expected physical Turn ID")
918
+ # Keep the facade's typed input conversion without constructing an
919
+ # AsyncTurnHandle: its constructor would acquire another subscription.
920
+ native_input = _sdk_inputs._to_wire_input(
921
+ _sdk_inputs._normalize_run_input(input)
922
+ )
923
+ try:
924
+ response = await self._client.turn_steer(
925
+ self.thread_id, expected_turn_id, native_input
926
+ )
927
+ except (asyncio.CancelledError, InvalidRequestError):
928
+ raise
929
+ except Exception as error:
930
+ raise GoalMutationStateUnknown(
931
+ "Codex Goal 追加消息结果未确认;不能自动重试。",
932
+ handle=self,
933
+ physical_turn_id=expected_turn_id,
934
+ ) from error
935
+ if getattr(response, "turn_id", None) != expected_turn_id:
936
+ raise GoalMutationStateUnknown(
937
+ "Codex Goal 追加消息响应的物理 Turn 无法确认。",
938
+ handle=self,
939
+ physical_turn_id=expected_turn_id,
940
+ )
941
+ return response
942
+
943
+ async def wait_terminal(
944
+ self,
945
+ activity_sink: Callable[
946
+ [TurnActivityNotificationProjection | None], None
947
+ ]
948
+ | None = None,
949
+ ) -> GoalStreamTerminal:
950
+ logical_turn_id = self._logical_turn_id
951
+ if logical_turn_id is None:
952
+ raise GoalControlError("Goal logical Turn 尚未建立。")
953
+ self._activity_tap.bind(activity_sink)
954
+ try:
955
+ async for _notification in self._stream:
956
+ pass
957
+ finally:
958
+ self._activity_tap.bind(None)
959
+ final_turn = getattr(self._state, "completed_turn", None)
960
+ physical_turn_id = getattr(final_turn, "id", None)
961
+ status = getattr(getattr(final_turn, "status", None), "value", None)
962
+ if not isinstance(physical_turn_id, str) or not isinstance(status, str):
963
+ raise GoalControlError("Goal 通知流结束但缺少最终物理 Turn。")
964
+ return GoalStreamTerminal(
965
+ logical_turn_id,
966
+ physical_turn_id,
967
+ status,
968
+ self._activity_tap.final_turn_diff(physical_turn_id),
969
+ )
970
+
971
+ async def pause(self) -> GoalPauseAck:
972
+ try:
973
+ response = await self._client.pause_goal(self.thread_id)
974
+ except asyncio.CancelledError:
975
+ raise
976
+ except Exception as error:
977
+ raise GoalMutationStateUnknown(
978
+ "Codex Goal 暂停结果未确认;不能自动重试。",
979
+ handle=self,
980
+ ) from error
981
+ snapshot = _goal_snapshot(getattr(response, "goal", None))
982
+ if snapshot.thread_id != self.thread_id or snapshot.status is not GoalStatus.PAUSED:
983
+ raise GoalMutationStateUnknown(
984
+ "Codex Goal 暂停响应无法确认。",
985
+ handle=self,
986
+ )
987
+ physical_turn_id = self.current_physical_turn_id()
988
+ if physical_turn_id is None:
989
+ return GoalPauseAck(snapshot, None, False)
990
+ try:
991
+ await self._client.turn_interrupt(self.thread_id, physical_turn_id)
992
+ except asyncio.CancelledError:
993
+ raise
994
+ except Exception as error:
995
+ raise GoalMutationStateUnknown(
996
+ "Goal 已暂停,但当前物理 Turn 的中断结果未确认。",
997
+ handle=self,
998
+ physical_turn_id=physical_turn_id,
999
+ ) from error
1000
+ return GoalPauseAck(snapshot, physical_turn_id, True)
1001
+
1002
+ async def aclose(self) -> None:
1003
+ await self._stream.aclose()
1004
+
1005
+
1006
+ def facade_migration_requirements() -> tuple[str, ...]:
1007
+ """Return public facade candidates that require deleting a gap shim."""
1008
+
1009
+ from openai_codex import AsyncThread
1010
+
1011
+ requirements: list[str] = []
1012
+ candidates = {
1013
+ "skills": (
1014
+ (AsyncCodex, "skills"),
1015
+ (AsyncCodex, "skills_list"),
1016
+ ),
1017
+ "skill-roots": (
1018
+ (AsyncCodex, "skills_extra_roots_set"),
1019
+ (AsyncCodex, "set_skill_roots"),
1020
+ (AsyncCodex, "set_skills_extra_roots"),
1021
+ ),
1022
+ "goal": (
1023
+ (AsyncCodex, "goal_get"),
1024
+ (AsyncThread, "goal_get"),
1025
+ (AsyncThread, "goal_set"),
1026
+ (AsyncThread, "goal_clear"),
1027
+ (AsyncThread, "goal_start"),
1028
+ ),
1029
+ "apps": (
1030
+ (AsyncCodex, "apps"),
1031
+ (AsyncCodex, "apps_list"),
1032
+ ),
1033
+ "thread-delete": (
1034
+ (AsyncCodex, "thread_delete"),
1035
+ (AsyncThread, "delete"),
1036
+ ),
1037
+ "side-boundary": (
1038
+ (AsyncCodex, "thread_inject_items"),
1039
+ (AsyncThread, "inject_items"),
1040
+ ),
1041
+ "thread-subscription": (
1042
+ (AsyncCodex, "thread_unsubscribe"),
1043
+ (AsyncThread, "unsubscribe"),
1044
+ ),
1045
+ }
1046
+ for capability, entries in candidates.items():
1047
+ names = tuple(f"{owner.__name__}.{name}" for owner, name in entries if hasattr(owner, name))
1048
+ if names:
1049
+ requirements.append(f"migration-required:{capability}:{','.join(names)}")
1050
+ return tuple(requirements)
1051
+
1052
+
1053
+ def require_no_facade_migration() -> None:
1054
+ requirements = facade_migration_requirements()
1055
+ if requirements:
1056
+ raise SdkFacadeMigrationRequired("; ".join(requirements))
1057
+
1058
+
1059
+ def _initialized_client(codex: AsyncCodex, *, capability: str) -> Any:
1060
+ if getattr(codex, "_initialized", False) is not True:
1061
+ raise SdkGapCapabilityUnavailable(
1062
+ f"{capability} requires an initialized AsyncCodex"
1063
+ )
1064
+ client = getattr(codex, "_client", None)
1065
+ if client is None or not callable(getattr(client, "request", None)):
1066
+ raise SdkGapCapabilityUnavailable(
1067
+ f"{capability} SDK ownership edge changed"
1068
+ )
1069
+ return client
1070
+
1071
+
1072
+ def _generated_type(name: str, *, capability: str) -> type[Any]:
1073
+ value = getattr(_generated, name, None)
1074
+ if not isinstance(value, type):
1075
+ raise SdkGapCapabilityUnavailable(
1076
+ f"{capability} generated model missing {name}"
1077
+ )
1078
+ return value
1079
+
1080
+
1081
+ def _require_model_fields(
1082
+ model: type[Any],
1083
+ *,
1084
+ capability: str,
1085
+ aliases: dict[str, str],
1086
+ ) -> None:
1087
+ fields = getattr(model, "model_fields", None)
1088
+ if not isinstance(fields, dict) or set(fields) != set(aliases):
1089
+ raise SdkGapCapabilityUnavailable(
1090
+ f"{capability} generated model fields changed for {model.__name__}"
1091
+ )
1092
+ for name, expected_alias in aliases.items():
1093
+ field = fields[name]
1094
+ actual_alias = getattr(field, "alias", None) or name
1095
+ if actual_alias != expected_alias:
1096
+ raise SdkGapCapabilityUnavailable(
1097
+ f"{capability} generated alias changed for {model.__name__}.{name}"
1098
+ )
1099
+
1100
+
1101
+ def _goal_snapshot(goal: Any) -> GoalSnapshot:
1102
+ if goal is None:
1103
+ raise GoalControlError("Codex Goal 响应缺少 goal。")
1104
+ status_value = getattr(getattr(goal, "status", None), "value", None)
1105
+ try:
1106
+ status = GoalStatus(status_value)
1107
+ except (TypeError, ValueError) as error:
1108
+ raise GoalControlError("Codex Goal 返回了未知状态。") from error
1109
+ return GoalSnapshot(
1110
+ thread_id=_trimmed_string(getattr(goal, "thread_id", None), "Goal thread_id"),
1111
+ objective=_trimmed_string(getattr(goal, "objective", None), "Goal objective"),
1112
+ status=status,
1113
+ token_budget=_optional_nonnegative_int(
1114
+ getattr(goal, "token_budget", None),
1115
+ "Goal token_budget",
1116
+ ),
1117
+ tokens_used=_nonnegative_int(getattr(goal, "tokens_used", None), "Goal tokens_used"),
1118
+ time_used_seconds=_nonnegative_int(
1119
+ getattr(goal, "time_used_seconds", None),
1120
+ "Goal time_used_seconds",
1121
+ ),
1122
+ created_at=_nonnegative_int(getattr(goal, "created_at", None), "Goal created_at"),
1123
+ updated_at=_nonnegative_int(getattr(goal, "updated_at", None), "Goal updated_at"),
1124
+ )
1125
+
1126
+
1127
+ def _validate_thread_id(thread_id: str) -> None:
1128
+ _trimmed_string(thread_id, "native Thread ID")
1129
+
1130
+
1131
+ def _validate_objective(objective: str) -> str:
1132
+ value = _trimmed_string(objective, "Goal objective")
1133
+ if len(value) > 4_000:
1134
+ raise ValueError("Goal objective 不能超过 4000 个字符。")
1135
+ return value
1136
+
1137
+
1138
+ def _trimmed_string(value: Any, label: str) -> str:
1139
+ if not isinstance(value, str) or not value or value.strip() != value:
1140
+ raise ValueError(f"{label} must be a non-empty trimmed string")
1141
+ return value
1142
+
1143
+
1144
+ def _nonnegative_int(value: Any, label: str) -> int:
1145
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
1146
+ raise ValueError(f"{label} must be a non-negative integer")
1147
+ return value
1148
+
1149
+
1150
+ def _optional_nonnegative_int(value: Any, label: str) -> int | None:
1151
+ return None if value is None else _nonnegative_int(value, label)