vidbyte-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. vidbyte_cli/__init__.py +11 -0
  2. vidbyte_cli/__main__.py +6 -0
  3. vidbyte_cli/cli.py +18 -0
  4. vidbyte_cli/commands/__init__.py +86 -0
  5. vidbyte_cli/commands/agent_options.py +53 -0
  6. vidbyte_cli/commands/agents/__init__.py +31 -0
  7. vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
  8. vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
  9. vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
  10. vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
  11. vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
  12. vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
  13. vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
  14. vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
  15. vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
  16. vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
  17. vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
  18. vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
  19. vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
  20. vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
  21. vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
  22. vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
  23. vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
  24. vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
  25. vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
  26. vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
  27. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
  28. vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
  29. vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
  30. vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
  31. vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
  32. vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
  33. vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
  34. vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
  35. vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
  36. vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
  37. vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
  38. vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
  39. vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
  40. vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
  41. vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
  42. vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
  43. vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
  44. vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
  45. vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
  46. vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
  47. vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
  48. vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
  49. vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
  50. vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
  51. vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
  52. vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
  53. vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
  54. vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
  55. vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
  56. vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
  57. vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
  58. vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
  59. vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
  60. vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
  61. vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
  62. vidbyte_cli/commands/agents/suggestion/render.py +77 -0
  63. vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
  64. vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
  65. vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
  66. vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
  67. vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
  68. vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
  69. vidbyte_cli/commands/auth/__init__.py +0 -0
  70. vidbyte_cli/commands/auth/login.py +100 -0
  71. vidbyte_cli/commands/auth/logout.py +41 -0
  72. vidbyte_cli/commands/auth/whoami.py +51 -0
  73. vidbyte_cli/commands/billing/__init__.py +20 -0
  74. vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
  75. vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
  76. vidbyte_cli/commands/billing/prompts/group.md +13 -0
  77. vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
  78. vidbyte_cli/commands/billing/prompts/library.py +18 -0
  79. vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
  80. vidbyte_cli/commands/billing/top_up.py +65 -0
  81. vidbyte_cli/commands/config/__init__.py +0 -0
  82. vidbyte_cli/commands/config/get.py +45 -0
  83. vidbyte_cli/commands/config/set.py +39 -0
  84. vidbyte_cli/commands/provider/__init__.py +9 -0
  85. vidbyte_cli/commands/provider/login.py +102 -0
  86. vidbyte_cli/commands/provider/logout.py +45 -0
  87. vidbyte_cli/commands/provider/whoami.py +49 -0
  88. vidbyte_cli/commands/research/__init__.py +5 -0
  89. vidbyte_cli/commands/research/add.py +41 -0
  90. vidbyte_cli/commands/research/options.py +134 -0
  91. vidbyte_cli/commands/research/render.py +157 -0
  92. vidbyte_cli/commands/research/resume.py +37 -0
  93. vidbyte_cli/commands/research/start.py +37 -0
  94. vidbyte_cli/commands/research/status.py +32 -0
  95. vidbyte_cli/commands/research/thread.py +30 -0
  96. vidbyte_cli/commands/research/threads.py +35 -0
  97. vidbyte_cli/commands/research/watch.py +93 -0
  98. vidbyte_cli/commands/runtime/__init__.py +23 -0
  99. vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
  100. vidbyte_cli/commands/runtime/doctor.py +44 -0
  101. vidbyte_cli/commands/runtime/list.py +47 -0
  102. vidbyte_cli/commands/runtime/persistence.py +141 -0
  103. vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
  104. vidbyte_cli/commands/runtime/stages.py +553 -0
  105. vidbyte_cli/commands/runtime/task_board.py +1342 -0
  106. vidbyte_cli/commands/setup/__init__.py +0 -0
  107. vidbyte_cli/commands/setup/doctor.py +64 -0
  108. vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
  109. vidbyte_cli/commands/setup/prompts/browser.md +7 -0
  110. vidbyte_cli/commands/setup/prompts/library.py +18 -0
  111. vidbyte_cli/commands/setup/prompts/star.md +13 -0
  112. vidbyte_cli/commands/setup/star.py +132 -0
  113. vidbyte_cli/lib/__init__.py +0 -0
  114. vidbyte_cli/lib/api/__init__.py +0 -0
  115. vidbyte_cli/lib/api/client.py +236 -0
  116. vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
  117. vidbyte_cli/lib/api/endpoints/auth.py +24 -0
  118. vidbyte_cli/lib/api/endpoints/billing.py +36 -0
  119. vidbyte_cli/lib/api/endpoints/research.py +103 -0
  120. vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
  121. vidbyte_cli/lib/api/problem.py +86 -0
  122. vidbyte_cli/lib/api/response.py +101 -0
  123. vidbyte_cli/lib/api/retry.py +116 -0
  124. vidbyte_cli/lib/api/runtime_payment.py +89 -0
  125. vidbyte_cli/lib/auth/__init__.py +16 -0
  126. vidbyte_cli/lib/auth/credentials.py +223 -0
  127. vidbyte_cli/lib/auth/input.py +59 -0
  128. vidbyte_cli/lib/auth/keyring_store.py +126 -0
  129. vidbyte_cli/lib/auth/provider_credentials.py +157 -0
  130. vidbyte_cli/lib/auth/provider_input.py +54 -0
  131. vidbyte_cli/lib/auth/provider_resolver.py +89 -0
  132. vidbyte_cli/lib/auth/provider_store.py +149 -0
  133. vidbyte_cli/lib/auth/provider_verifier.py +219 -0
  134. vidbyte_cli/lib/auth/resolver.py +63 -0
  135. vidbyte_cli/lib/auth/verifier.py +38 -0
  136. vidbyte_cli/lib/config/__init__.py +25 -0
  137. vidbyte_cli/lib/config/atomic.py +47 -0
  138. vidbyte_cli/lib/config/config.py +141 -0
  139. vidbyte_cli/lib/config/migration.py +81 -0
  140. vidbyte_cli/lib/config/models.py +145 -0
  141. vidbyte_cli/lib/config/paths.py +66 -0
  142. vidbyte_cli/lib/config/resolver.py +137 -0
  143. vidbyte_cli/lib/constants/__init__.py +1 -0
  144. vidbyte_cli/lib/constants/runtime.py +186 -0
  145. vidbyte_cli/lib/errors/__init__.py +12 -0
  146. vidbyte_cli/lib/errors/cli_error.py +86 -0
  147. vidbyte_cli/lib/errors/codes.py +43 -0
  148. vidbyte_cli/lib/errors/failures.py +2910 -0
  149. vidbyte_cli/lib/errors/handler.py +72 -0
  150. vidbyte_cli/lib/files/__init__.py +6 -0
  151. vidbyte_cli/lib/files/documents.py +94 -0
  152. vidbyte_cli/lib/files/store.py +156 -0
  153. vidbyte_cli/lib/io/__init__.py +16 -0
  154. vidbyte_cli/lib/io/attachments.py +129 -0
  155. vidbyte_cli/lib/io/codex_attachments.py +47 -0
  156. vidbyte_cli/lib/io/prompt.py +71 -0
  157. vidbyte_cli/lib/io/streams.py +37 -0
  158. vidbyte_cli/lib/io/terminal.py +66 -0
  159. vidbyte_cli/lib/output/__init__.py +7 -0
  160. vidbyte_cli/lib/output/formats.py +29 -0
  161. vidbyte_cli/lib/output/manager.py +95 -0
  162. vidbyte_cli/lib/output/models.py +49 -0
  163. vidbyte_cli/lib/runtime/__init__.py +9 -0
  164. vidbyte_cli/lib/runtime/application.py +134 -0
  165. vidbyte_cli/lib/runtime/context.py +250 -0
  166. vidbyte_cli/lib/runtime/options.py +159 -0
  167. vidbyte_cli/lib/runtime/version.py +30 -0
  168. vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
  169. vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
  170. vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
  171. vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
  172. vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
  173. vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
  174. vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
  175. vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
  176. vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
  177. vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
  178. vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
  179. vidbyte_cli/services/__init__.py +5 -0
  180. vidbyte_cli/services/ensemble/__init__.py +7 -0
  181. vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
  182. vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
  183. vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
  184. vidbyte_cli/services/ensemble/prompts/library.py +188 -0
  185. vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
  186. vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
  187. vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
  188. vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
  189. vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
  190. vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
  191. vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
  192. vidbyte_cli/services/ensemble/runner.py +30 -0
  193. vidbyte_cli/services/ensemble/sdk.py +134 -0
  194. vidbyte_cli/services/ensemble/service.py +233 -0
  195. vidbyte_cli/services/ensemble/settings.py +60 -0
  196. vidbyte_cli/services/persistence/__init__.py +6 -0
  197. vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
  198. vidbyte_cli/services/persistence/prompts/library.py +46 -0
  199. vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
  200. vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
  201. vidbyte_cli/services/persistence/runner.py +35 -0
  202. vidbyte_cli/services/persistence/session.py +116 -0
  203. vidbyte_cli/services/suggestions/__init__.py +7 -0
  204. vidbyte_cli/services/suggestions/categories.py +270 -0
  205. vidbyte_cli/services/suggestions/context.py +222 -0
  206. vidbyte_cli/services/suggestions/extra_compute.py +53 -0
  207. vidbyte_cli/services/suggestions/handoff.py +173 -0
  208. vidbyte_cli/services/suggestions/message_tools.py +47 -0
  209. vidbyte_cli/services/suggestions/project.py +253 -0
  210. vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
  211. vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
  212. vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
  213. vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
  214. vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
  215. vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
  216. vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
  217. vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
  218. vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
  219. vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
  220. vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
  221. vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
  222. vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
  223. vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
  224. vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
  225. vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
  226. vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
  227. vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
  228. vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
  229. vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
  230. vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
  231. vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
  232. vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
  233. vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
  234. vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
  235. vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
  236. vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
  237. vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
  238. vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
  239. vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
  240. vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
  241. vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
  242. vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
  243. vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
  244. vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
  245. vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
  246. vidbyte_cli/services/suggestions/prompts/library.py +72 -0
  247. vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
  248. vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
  249. vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
  250. vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
  251. vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
  252. vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
  253. vidbyte_cli/services/suggestions/sdk.py +228 -0
  254. vidbyte_cli/services/suggestions/selection.py +78 -0
  255. vidbyte_cli/services/suggestions/service.py +540 -0
  256. vidbyte_cli/types/__init__.py +0 -0
  257. vidbyte_cli/types/api.py +101 -0
  258. vidbyte_cli/types/attachments.py +79 -0
  259. vidbyte_cli/types/ensemble.py +189 -0
  260. vidbyte_cli/types/provider.py +62 -0
  261. vidbyte_cli/types/research.py +260 -0
  262. vidbyte_cli/types/runtime.py +1098 -0
  263. vidbyte_cli/types/suggestions.py +518 -0
  264. vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
  265. vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
  266. vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
  267. vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
  268. vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  269. vidbyte_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,540 @@
1
+ """Runs the SDK-backed suggestion generation, critique, and revision workflow.
2
+
3
+ One generator agent owns the whole run: its first turn drafts the slate, and each
4
+ later turn carries one general critic handoff and returns the complete revised
5
+ slate on the same native thread, so every critic handoff stays in its history.
6
+ Message tools let the generator stop the run to ask its parent for input, and let
7
+ a critic stop its review early to message the generator directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import time
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass
16
+ from typing import Any, Literal, cast
17
+ from uuid import uuid4
18
+
19
+ from ...lib.errors.failures import (
20
+ SuggestionInputInvalid,
21
+ SuggestionProviderFailed,
22
+ SuggestionSdkUnavailable,
23
+ )
24
+ from ...types.suggestions import (
25
+ RunStatus,
26
+ StopReason,
27
+ SuggestionAgentContext,
28
+ SuggestionCandidateBatch,
29
+ SuggestionCriticHandoff,
30
+ SuggestionDraft,
31
+ SuggestionFeedback,
32
+ SuggestionHorizon,
33
+ SuggestionIdea,
34
+ SuggestionRequest,
35
+ SuggestionResult,
36
+ )
37
+ from .categories import SuggestionCategories
38
+ from .extra_compute import ExtraComputeService
39
+ from .handoff import SuggestionHandoffBuilder
40
+ from .message_tools import SuggestionMessageTool
41
+ from .project import REJECTED_FEEDBACK_KIND, SuggestionProject, SuggestionProjectKey
42
+ from .prompts.library import SuggestionPrompts
43
+ from .sdk import (
44
+ SuggestionAgent,
45
+ SuggestionAgentSettingsInput,
46
+ SuggestionSdk,
47
+ SuggestionTextInput,
48
+ )
49
+ from .selection import SuggestionSelection
50
+
51
+ # The first draft is wider than the request so revision has material to cut.
52
+ _POOL_MULTIPLE = 2
53
+ _POOL_CAP = 40
54
+
55
+
56
+ class _WorkflowLimit(Exception):
57
+ """Internal control flow for a caller-owned time or token boundary."""
58
+
59
+ def __init__(self, reason: StopReason) -> None:
60
+ self.reason = reason
61
+
62
+
63
+ class _AgentMessage(Exception):
64
+ """Internal control flow for an agent that stopped its turn by sending a message."""
65
+
66
+ def __init__(self, message: str) -> None:
67
+ self.message = message
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class _WorkflowOutcome:
72
+ """Final ideas plus accounting and the reason the loop stopped."""
73
+
74
+ ideas: tuple[SuggestionIdea, ...]
75
+ usage: dict[str, int]
76
+ stop_reason: StopReason
77
+ warnings: tuple[str, ...] = ()
78
+ parent_message: str | None = None
79
+
80
+
81
+ class SuggestionService:
82
+ """Coordinates independent SDK agents and deterministic selection boundaries."""
83
+
84
+ def __init__(self, sdk: Any | None = None) -> None:
85
+ # Injection keeps offline verification at the same typed agent boundary as production.
86
+ self._sdk = sdk
87
+ self._categories = SuggestionCategories()
88
+ self._selection = SuggestionSelection()
89
+ self._handoffs = SuggestionHandoffBuilder()
90
+ self._prompts = SuggestionPrompts()
91
+
92
+ def run(self, request: SuggestionRequest) -> SuggestionResult:
93
+ """Validate one request, run the loop, and return a versioned result."""
94
+ if not isinstance(request, SuggestionRequest):
95
+ raise SuggestionInputInvalid()
96
+ # A dry run validates inputs and reports settings without loading the SDK.
97
+ if request.settings.dry_run:
98
+ return self._result(request, _WorkflowOutcome((), {}, StopReason.DRY_RUN))
99
+ sdk = self._sdk or SuggestionSdk.load()
100
+ # Every unexpected failure inside the loop surfaces as one typed provider failure.
101
+ try:
102
+ outcome = asyncio.run(self._run(request, sdk))
103
+ except (SuggestionSdkUnavailable, SuggestionProviderFailed):
104
+ raise
105
+ except Exception as error:
106
+ raise SuggestionProviderFailed(error) from error
107
+ return self._result(request, outcome)
108
+
109
+ async def _run(self, request: SuggestionRequest, sdk: Any) -> _WorkflowOutcome:
110
+ # Accounting and caller thresholds shared by every agent turn in this run.
111
+ started = time.monotonic()
112
+ usage = {"tokens": 0, "agent_calls": 0, "generation_calls": 0, "critique_calls": 0}
113
+ categories = request.settings.categories or self._categories.ids()
114
+ pool_size = min(request.settings.requested_count * _POOL_MULTIPLE, _POOL_CAP)
115
+ warnings: list[str] = list(request.context_warnings)
116
+ current: tuple[SuggestionIdea, ...] = ()
117
+ # Only the persistent generator can reach the parent; its message ends the run.
118
+ parent = SuggestionMessageTool("message_parent", self._prompts)
119
+ try:
120
+ if request.settings.extra_compute:
121
+ # Extra compute drafts in fresh per-category agents, then seeds the one
122
+ # persistent generator with the merged pool so revision keeps one thread.
123
+ drafts = await ExtraComputeService(self._categories, self._prompts).generate(
124
+ request,
125
+ categories,
126
+ pool_size,
127
+ lambda role, prompt, context, model: self._call_agent(
128
+ sdk,
129
+ role,
130
+ self._prompts.generator_system(),
131
+ prompt,
132
+ context,
133
+ model,
134
+ SuggestionCandidateBatch,
135
+ request,
136
+ started,
137
+ usage,
138
+ "generation",
139
+ ),
140
+ )
141
+ current = self._ideas_from_drafts(drafts, request, 1)
142
+ seed = tuple(idea.to_draft() for idea in current)
143
+ generator = self._generator(request, sdk, categories, parent, seed)
144
+ else:
145
+ # The generator drafts the first slate on the thread it keeps for the run.
146
+ generator = self._generator(request, sdk, categories, parent)
147
+ batch = await self._ask(
148
+ sdk,
149
+ generator,
150
+ self._prompts.generator_turn(request.goal, pool_size),
151
+ SuggestionCandidateBatch,
152
+ request,
153
+ started,
154
+ usage,
155
+ "generation",
156
+ parent,
157
+ )
158
+ current = self._ideas_from_drafts(batch.ideas, request, 1)
159
+ if not current:
160
+ return _WorkflowOutcome((), usage, StopReason.COUNT_SHORTFALL, tuple(warnings))
161
+
162
+ messages = 0
163
+ for round_index in range(request.settings.rounds):
164
+ # A fresh critic grades the latest slate and returns one general handoff.
165
+ # It gets the message tool only while the caller's --max-messages remains.
166
+ critic_tool = (
167
+ SuggestionMessageTool("message_generator", self._prompts)
168
+ if messages < request.settings.max_messages
169
+ else None
170
+ )
171
+ try:
172
+ handoff = await self._critique(
173
+ request, sdk, categories, current, started, usage, critic_tool
174
+ )
175
+ prompt = self._prompts.revision_turn(
176
+ request.goal, request.settings.requested_count, handoff.handoff
177
+ )
178
+ except _AgentMessage as sent:
179
+ # A critic that stopped early sends its message in place of the review.
180
+ messages += 1
181
+ prompt = self._prompts.critic_message_turn(
182
+ request.goal, request.settings.requested_count, sent.message
183
+ )
184
+ # The handoff is the generator's next turn, so its thread accumulates
185
+ # generation, critique, revision, critique, ... across every round.
186
+ batch = await self._ask(
187
+ sdk,
188
+ generator,
189
+ prompt,
190
+ SuggestionCandidateBatch,
191
+ request,
192
+ started,
193
+ usage,
194
+ "revision",
195
+ parent,
196
+ )
197
+ # The generator owns the slate, so its complete reply replaces the last one.
198
+ current = self._ideas_from_drafts(batch.ideas, request, round_index + 2)
199
+ if not current:
200
+ break
201
+ except _AgentMessage as sent:
202
+ # The generator asked its parent for input, so the run stops without a slate.
203
+ return _WorkflowOutcome(
204
+ (), usage, StopReason.PARENT_MESSAGE, tuple(warnings), sent.message
205
+ )
206
+ except _WorkflowLimit as limit:
207
+ # A caller threshold ends the run with the latest generator-owned slate.
208
+ return _WorkflowOutcome(
209
+ self._finalize(current, request), usage, limit.reason, tuple(warnings)
210
+ )
211
+ ideas = self._finalize(current, request)
212
+ reason = (
213
+ StopReason.COMPLETED
214
+ if len(ideas) >= request.settings.requested_count
215
+ else StopReason.COUNT_SHORTFALL
216
+ )
217
+ return _WorkflowOutcome(ideas, usage, reason, tuple(warnings))
218
+
219
+ def _generator(
220
+ self,
221
+ request: SuggestionRequest,
222
+ sdk: Any,
223
+ categories: tuple[str, ...],
224
+ parent: SuggestionMessageTool,
225
+ candidates: tuple[SuggestionDraft, ...] = (),
226
+ ) -> SuggestionAgent:
227
+ # Builds the run's single generator; reusing the object resumes its native thread.
228
+ return self._agent(
229
+ sdk,
230
+ "generator",
231
+ self._prompts.generator_system(),
232
+ self._stage_context(request, categories, candidates),
233
+ None,
234
+ SuggestionCandidateBatch,
235
+ request,
236
+ (parent,),
237
+ )
238
+
239
+ async def _critique(
240
+ self,
241
+ request: SuggestionRequest,
242
+ sdk: Any,
243
+ categories: tuple[str, ...],
244
+ ideas: tuple[SuggestionIdea, ...],
245
+ started: float,
246
+ usage: dict[str, int],
247
+ tool: SuggestionMessageTool | None,
248
+ ) -> SuggestionCriticHandoff:
249
+ # Each round gets an independent critic that sees the slate but no generator history.
250
+ context = self._stage_context(request, categories, tuple(idea.to_draft() for idea in ideas))
251
+ ids = ", ".join(idea.id for idea in ideas)
252
+ prompt = self._prompts.critic_turn(request.goal, ids)
253
+ return cast(
254
+ SuggestionCriticHandoff,
255
+ await self._call_agent(
256
+ sdk,
257
+ "critic",
258
+ self._prompts.critic_system(),
259
+ prompt,
260
+ context,
261
+ request.settings.critic_model,
262
+ SuggestionCriticHandoff,
263
+ request,
264
+ started,
265
+ usage,
266
+ "critique",
267
+ tool,
268
+ ),
269
+ )
270
+
271
+ async def _call_agent(
272
+ self,
273
+ sdk: Any,
274
+ role: Literal["generator", "critic"],
275
+ system_prompt: str,
276
+ prompt: str,
277
+ context: SuggestionAgentContext,
278
+ model: str | None,
279
+ schema: type[Any],
280
+ request: SuggestionRequest,
281
+ started: float,
282
+ usage: dict[str, int],
283
+ phase: str,
284
+ tool: SuggestionMessageTool | None = None,
285
+ ) -> Any:
286
+ # One fresh agent answering one turn: the critic and extra-compute fan-out shape.
287
+ self._check_limit(request, started, usage)
288
+ tools = () if tool is None else (tool,)
289
+ agent = self._agent(sdk, role, system_prompt, context, model, schema, request, tools)
290
+ return await self._ask(sdk, agent, prompt, schema, request, started, usage, phase, tool)
291
+
292
+ def _agent(
293
+ self,
294
+ sdk: Any,
295
+ role: Literal["generator", "critic"],
296
+ system_prompt: str,
297
+ context: SuggestionAgentContext,
298
+ model: str | None,
299
+ schema: type[Any],
300
+ request: SuggestionRequest,
301
+ tools: tuple[SuggestionMessageTool, ...] = (),
302
+ ) -> SuggestionAgent:
303
+ # Every agent is read-only and bound to one output schema for all of its turns.
304
+ settings = sdk.agent_settings(
305
+ SuggestionAgentSettingsInput(
306
+ role=role,
307
+ system_prompt=system_prompt,
308
+ context=context,
309
+ output_schema=schema,
310
+ provider=request.settings.provider,
311
+ model=model,
312
+ tools=tools,
313
+ )
314
+ )
315
+ return cast(SuggestionAgent, sdk.agent(settings))
316
+
317
+ async def _ask(
318
+ self,
319
+ sdk: Any,
320
+ agent: SuggestionAgent,
321
+ prompt: str,
322
+ schema: type[Any],
323
+ request: SuggestionRequest,
324
+ started: float,
325
+ usage: dict[str, int],
326
+ phase: str,
327
+ tool: SuggestionMessageTool | None = None,
328
+ ) -> Any:
329
+ # Runs one turn inside the caller's time and token limits and parses its artifact.
330
+ self._check_limit(request, started, usage)
331
+ run_input = sdk.run_input(
332
+ SuggestionTextInput(self._with_output_budget(prompt, request), request.attachments)
333
+ )
334
+ remaining = self._remaining_seconds(request, started)
335
+ try:
336
+ if remaining is None:
337
+ reply = await self._arun(agent, run_input, tool)
338
+ else:
339
+ async with asyncio.timeout(remaining):
340
+ reply = await self._arun(agent, run_input, tool)
341
+ except TimeoutError as error:
342
+ raise _WorkflowLimit(StopReason.TIME_LIMIT) from error
343
+ except _AgentMessage:
344
+ # A stopped turn still counts as a call, but it returns no provider usage.
345
+ self._record_usage(None, usage, phase)
346
+ raise
347
+ self._record_usage(reply, usage, phase)
348
+ structured = getattr(reply, "structured", None)
349
+ if isinstance(structured, schema):
350
+ return structured
351
+ if isinstance(structured, Mapping):
352
+ return schema.model_validate(structured)
353
+ raise ValueError(f"{phase} agent returned no structured artifact")
354
+
355
+ async def _arun(
356
+ self, agent: SuggestionAgent, run_input: Any, tool: SuggestionMessageTool | None
357
+ ) -> Any:
358
+ # A message tool stops the turn: the run is cancelled as soon as its message lands,
359
+ # so nothing the model does after the call can reach the workflow.
360
+ if tool is None:
361
+ return await agent.arun(run_input)
362
+ run = asyncio.ensure_future(agent.arun(run_input))
363
+ stop = asyncio.ensure_future(tool.stopped.wait())
364
+ try:
365
+ await asyncio.wait((run, stop), return_when=asyncio.FIRST_COMPLETED)
366
+ finally:
367
+ for task in (run, stop):
368
+ task.cancel()
369
+ # The message wins even when the turn also finished, because the call means stop.
370
+ if tool.message is not None:
371
+ await asyncio.gather(run, return_exceptions=True)
372
+ raise _AgentMessage(tool.message)
373
+ return run.result()
374
+
375
+ def _ideas_from_drafts(
376
+ self, drafts: tuple[SuggestionDraft, ...], request: SuggestionRequest, revision: int
377
+ ) -> tuple[SuggestionIdea, ...]:
378
+ # Identity is positional, which is how the critic and the revision turn name candidates.
379
+ return tuple(
380
+ self._idea_from_draft(draft, request, f"idea-{index:03d}", revision, index)
381
+ for index, draft in enumerate(drafts, 1)
382
+ )
383
+
384
+ def _idea_from_draft(
385
+ self,
386
+ draft: SuggestionDraft,
387
+ request: SuggestionRequest,
388
+ idea_id: str,
389
+ revision: int,
390
+ rank: int,
391
+ ) -> SuggestionIdea:
392
+ # Adds CLI-owned identity and a provisional handoff to one model draft.
393
+ values = draft.model_dump(exclude={"idea_id"})
394
+ handoff = self._handoffs.build_draft(draft, request, idea_id, revision)
395
+ return SuggestionIdea(**values, id=idea_id, revision=revision, rank=rank, handoff=handoff)
396
+
397
+ def _finalize(
398
+ self, ideas: tuple[SuggestionIdea, ...], request: SuggestionRequest
399
+ ) -> tuple[SuggestionIdea, ...]:
400
+ # Deterministic gates the model cannot talk its way past, applied to the final slate.
401
+ allowed = set(self._categories.ids())
402
+ manifest_refs = {
403
+ entry.ref for entry in request.context_manifest if entry.status != "omitted"
404
+ }
405
+ # Without a manifest, the supplied context items are the only citable evidence.
406
+ if not manifest_refs:
407
+ manifest_refs = {item.ref for item in request.context_items}
408
+ selected = self._selection.validate_categories(ideas, allowed)
409
+ selected = self._selection.validate_evidence(selected, manifest_refs)
410
+ selected = self._selection.deduplicate(selected)
411
+ selected = self._selection.suppress_rejected(selected, self._rejected_terms(request))
412
+ if request.settings.horizon is not SuggestionHorizon.ANY:
413
+ selected = tuple(
414
+ idea for idea in selected if idea.horizon.value == request.settings.horizon.value
415
+ )
416
+ # Handoffs are rebuilt last so they embed only evidence that survived the gates.
417
+ selected = tuple(self._handoff_refresh(idea, request) for idea in selected)
418
+ return self._selection.rank(selected, request.settings.requested_count)
419
+
420
+ def _handoff_refresh(self, idea: SuggestionIdea, request: SuggestionRequest) -> SuggestionIdea:
421
+ # Replaces the provisional draft handoff with the final evidence-bearing one.
422
+ return idea.model_copy(update={"handoff": self._handoffs.build(idea, request)})
423
+
424
+ def _stage_context(
425
+ self,
426
+ request: SuggestionRequest,
427
+ categories: tuple[str, ...],
428
+ candidates: tuple[SuggestionDraft, ...] = (),
429
+ ) -> SuggestionAgentContext:
430
+ # One window type per stage: what a stage omits, it simply never passes here.
431
+ return SuggestionAgentContext.for_stage(
432
+ request.context,
433
+ self._categories.prompt_section(categories),
434
+ candidates,
435
+ )
436
+
437
+ def _check_limit(
438
+ self, request: SuggestionRequest, started: float, usage: dict[str, int]
439
+ ) -> None:
440
+ # Raised before a turn starts, so a limit never discards a finished reply.
441
+ if (
442
+ request.settings.max_total_tokens is not None
443
+ and usage["tokens"] >= request.settings.max_total_tokens
444
+ ):
445
+ raise _WorkflowLimit(StopReason.TOKEN_LIMIT)
446
+ if self._remaining_seconds(request, started) == 0:
447
+ raise _WorkflowLimit(StopReason.TIME_LIMIT)
448
+
449
+ def _with_output_budget(self, prompt: str, request: SuggestionRequest) -> str:
450
+ # Carries the caller's generous output ceiling into every typed model turn.
451
+ limit = request.settings.max_output_tokens
452
+ if limit is None:
453
+ return prompt
454
+ return f"{prompt}\n\nKeep the structured response within {limit} output tokens."
455
+
456
+ def _remaining_seconds(self, request: SuggestionRequest, started: float) -> float | None:
457
+ # None means no deadline; zero means the deadline has already passed.
458
+ limit = request.settings.timeout_seconds
459
+ if limit is None:
460
+ return None
461
+ remaining = limit - (time.monotonic() - started)
462
+ return max(remaining, 0.0)
463
+
464
+ def _record_usage(self, reply: Any, usage: dict[str, int], phase: str) -> None:
465
+ # Counts calls per phase and adds the provider's token total when it reports one.
466
+ usage["agent_calls"] += 1
467
+ usage[f"{phase}_calls"] = usage.get(f"{phase}_calls", 0) + 1
468
+ codex = getattr(reply, "codex", None)
469
+ snapshot = getattr(codex, "last_usage", None) or getattr(codex, "usage", None)
470
+ tokens = int(getattr(snapshot, "total_tokens", 0) or 0)
471
+ usage["tokens"] += max(tokens, 0)
472
+
473
+ def _rejected_terms(self, request: SuggestionRequest) -> tuple[str, ...]:
474
+ # Rejected project feedback carries a label and optional reason, so only its verbatim
475
+ # suggestion text is used as a suppression needle.
476
+ terms: list[str] = []
477
+ for item in request.context_items:
478
+ if item.kind == REJECTED_FEEDBACK_KIND:
479
+ terms.append(SuggestionFeedback.suggestion_from_context(item.content))
480
+ elif item.kind in {"completed", "in_progress", "avoid", "mistakes", "forbidden"}:
481
+ terms.append(item.content)
482
+ return tuple(terms)
483
+
484
+ def _result(self, request: SuggestionRequest, outcome: _WorkflowOutcome) -> SuggestionResult:
485
+ # Converts the loop outcome into the versioned envelope the command renders.
486
+ ideas = outcome.ideas
487
+ warnings = list(outcome.warnings)
488
+ if outcome.stop_reason is StopReason.COUNT_SHORTFALL and ideas:
489
+ warnings.append(
490
+ f"Only {len(ideas)} worthwhile suggestions survived review; no filler was added."
491
+ )
492
+ status = (
493
+ RunStatus.NEEDS_INPUT
494
+ if outcome.stop_reason is StopReason.PARENT_MESSAGE
495
+ else RunStatus.NO_SUGGESTIONS
496
+ if not ideas
497
+ else RunStatus.COMPLETE
498
+ if outcome.stop_reason is StopReason.COMPLETED
499
+ else RunStatus.PARTIAL
500
+ )
501
+ # Names the context kinds the caller never supplied, so gaps are visible, not guessed.
502
+ present_kinds = {item.kind for item in request.context_items}
503
+ missing = tuple(
504
+ kind
505
+ for kind in (
506
+ "completed",
507
+ "in_progress",
508
+ "decision",
509
+ "constraint",
510
+ "risks",
511
+ "trajectory",
512
+ )
513
+ if kind not in present_kinds
514
+ )
515
+ return SuggestionResult(
516
+ run_id=f"sug-{uuid4().hex[:12]}",
517
+ status=status,
518
+ goal=request.goal,
519
+ project_key=request.project_key,
520
+ requested_count=request.settings.requested_count,
521
+ returned_count=len(ideas),
522
+ settings=request.settings,
523
+ context_manifest=request.context_manifest,
524
+ ideas=ideas,
525
+ category_coverage=self._selection.coverage(ideas),
526
+ missing_context=missing,
527
+ warnings=tuple(dict.fromkeys(warnings)),
528
+ usage=outcome.usage,
529
+ stop_reason=outcome.stop_reason,
530
+ parent_message=outcome.parent_message,
531
+ feedback_capture=(
532
+ SuggestionProject.feedback_capture(SuggestionProjectKey(request.project_key))
533
+ if request.project_key
534
+ else None
535
+ ),
536
+ prompt_version=request.prompt_version,
537
+ )
538
+
539
+
540
+ __all__ = ["SuggestionService"]
File without changes
@@ -0,0 +1,101 @@
1
+ """The transport envelope and the identity behind a validated API key.
2
+
3
+ This is *how* the CLI talks to the backend, independent of any one domain — a product's own
4
+ wire shapes belong beside that product, the way types/research.py owns the research surface.
5
+ Keep the envelope field names in sync with the backend response wrapper; the ApiClient
6
+ unwraps `data` so callers only ever see the typed payload.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Generic, Literal, TypeVar
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ class ApiError(BaseModel):
19
+ # Structured backend error; mapped to a CliError before it ever reaches the user.
20
+ code: str
21
+ title: str
22
+ detail: str
23
+
24
+
25
+ class ApiUsageRemediation(BaseModel):
26
+ """Safe, bounded instructions for restoring a Vidbyte API balance."""
27
+
28
+ model_config = ConfigDict(extra="forbid", frozen=True)
29
+
30
+ action: Literal["top_up_api_balance"]
31
+ requires_user_approval: bool
32
+ topup_method: Literal["POST"]
33
+ topup_path: str = Field(min_length=1, max_length=128, pattern=r"^/[A-Za-z0-9_./{}-]+$")
34
+ cli_command: Literal["vidbyte-cli billing top-up --confirm"]
35
+ minimum_topup_cents: int = Field(ge=1, le=1_000_000)
36
+ supported_payment_methods: tuple[Literal["x402", "mpp"], ...] = Field(
37
+ min_length=1, max_length=4
38
+ )
39
+ browser_url: str = Field(min_length=1, max_length=512, pattern=r"^https://")
40
+ retry_original_operation: bool
41
+ steps: tuple[str, ...] = Field(min_length=1, max_length=20)
42
+
43
+
44
+ class ApiUsageExhaustedProblem(BaseModel):
45
+ """The backend's allowlisted HTTP 402 account-balance problem."""
46
+
47
+ model_config = ConfigDict(extra="forbid", frozen=True)
48
+
49
+ error: Literal[True]
50
+ title: str = Field(min_length=1, max_length=256)
51
+ subtitle: str = Field(min_length=1, max_length=512)
52
+ description: str = Field(min_length=1, max_length=8192)
53
+ code: Literal["api_usage_exhausted", "usage_credit_exhausted"]
54
+ incident_id: str = Field(min_length=1, max_length=64)
55
+ remediation: ApiUsageRemediation
56
+
57
+
58
+ class BillingTopUpResult(BaseModel):
59
+ """The safe success fields returned after an API-balance top-up."""
60
+
61
+ model_config = ConfigDict(extra="forbid", frozen=True)
62
+
63
+ credited_cents: int = Field(ge=1)
64
+ rail: str = Field(min_length=1, max_length=32)
65
+ payment_ref: str | None = Field(default=None, max_length=256)
66
+ available_balance_cents: int = Field(ge=0)
67
+
68
+
69
+ class ApiPagination(BaseModel):
70
+ limit: int
71
+ page: int
72
+ total: int | None = None
73
+
74
+
75
+ class ApiEnvelope(BaseModel, Generic[T]):
76
+ # Standard response wrapper the ApiClient unwraps so callers see only `data`.
77
+ success: bool
78
+ message: str | None = None
79
+ data: T | None = None
80
+ error: ApiError | None = None
81
+ pagination: ApiPagination | None = None
82
+
83
+
84
+ class KeyIdentity(BaseModel):
85
+ """Non-secret identity behind a validated API key.
86
+
87
+ `extra="ignore"` is the one deliberate departure from this repo's `extra="forbid"` default.
88
+ The success body also carries a live, long-lived session token this CLI has no use for, and
89
+ ignoring it keeps that token out of every modelled field, output document, and log line.
90
+ The body's `email` is dropped for a different reason: the backend sets it to the user id,
91
+ so presenting it as an email address would be a lie.
92
+
93
+ `success` is modelled rather than ignored so a response that declares its own failure is a
94
+ detectable protocol violation instead of a silent approval.
95
+ """
96
+
97
+ model_config = ConfigDict(extra="ignore", frozen=True)
98
+
99
+ success: bool
100
+ username: str = Field(min_length=1, max_length=256)
101
+ account_tier: str = Field(min_length=1, max_length=64)