talkops-opscloud 0.3.0b1__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 (309) hide show
  1. opscloud/__init__.py +5 -0
  2. opscloud/__main__.py +6 -0
  3. opscloud/_constants.py +53 -0
  4. opscloud/_debug.py +145 -0
  5. opscloud/_debug_buffer.py +250 -0
  6. opscloud/_textual_patches.py +367 -0
  7. opscloud/_version.py +15 -0
  8. opscloud/agent/__init__.py +27 -0
  9. opscloud/agent/config.py +168 -0
  10. opscloud/agent/factory.py +1103 -0
  11. opscloud/approval_mode.py +44 -0
  12. opscloud/backend/__init__.py +49 -0
  13. opscloud/backend/composite.py +89 -0
  14. opscloud/backend/local.py +183 -0
  15. opscloud/backend/registry.py +79 -0
  16. opscloud/backend/sandbox_config.py +107 -0
  17. opscloud/backend/sandbox_factory.py +432 -0
  18. opscloud/built_in_skills/remember/SKILL.md +124 -0
  19. opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
  20. opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
  21. opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
  22. opscloud/cli/__init__.py +1 -0
  23. opscloud/cli/commands/__init__.py +15 -0
  24. opscloud/cli/commands/auth.py +45 -0
  25. opscloud/cli/commands/doctor.py +100 -0
  26. opscloud/cli/commands/mcp.py +137 -0
  27. opscloud/cli/commands/threads.py +51 -0
  28. opscloud/cli/interactive.py +154 -0
  29. opscloud/cli/main.py +903 -0
  30. opscloud/cli/non_interactive.py +425 -0
  31. opscloud/cli/server_manager.py +58 -0
  32. opscloud/commands/__init__.py +19 -0
  33. opscloud/commands/_base.py +110 -0
  34. opscloud/commands/_guards.py +52 -0
  35. opscloud/commands/_router.py +146 -0
  36. opscloud/commands/_types.py +36 -0
  37. opscloud/commands/core/__init__.py +47 -0
  38. opscloud/commands/core/auth.py +94 -0
  39. opscloud/commands/core/bug.py +46 -0
  40. opscloud/commands/core/clear.py +106 -0
  41. opscloud/commands/core/cloud.py +185 -0
  42. opscloud/commands/core/compact.py +128 -0
  43. opscloud/commands/core/config_cmd.py +120 -0
  44. opscloud/commands/core/context.py +220 -0
  45. opscloud/commands/core/cost.py +44 -0
  46. opscloud/commands/core/doctor.py +535 -0
  47. opscloud/commands/core/effort.py +108 -0
  48. opscloud/commands/core/exit_cmd.py +41 -0
  49. opscloud/commands/core/fast.py +148 -0
  50. opscloud/commands/core/help_cmd.py +94 -0
  51. opscloud/commands/core/mcp.py +134 -0
  52. opscloud/commands/core/model.py +109 -0
  53. opscloud/commands/core/permissions.py +196 -0
  54. opscloud/commands/core/plugins.py +92 -0
  55. opscloud/commands/core/pool.py +108 -0
  56. opscloud/commands/core/resume.py +97 -0
  57. opscloud/commands/core/skills.py +178 -0
  58. opscloud/commands/power/__init__.py +51 -0
  59. opscloud/commands/power/agents.py +98 -0
  60. opscloud/commands/power/btw.py +85 -0
  61. opscloud/commands/power/copy.py +97 -0
  62. opscloud/commands/power/goal.py +548 -0
  63. opscloud/commands/power/loop.py +295 -0
  64. opscloud/commands/power/memory.py +287 -0
  65. opscloud/commands/power/review.py +152 -0
  66. opscloud/commands/power/rubric.py +287 -0
  67. opscloud/commands/power/runtime.py +321 -0
  68. opscloud/commands/power/skill_creator.py +70 -0
  69. opscloud/commands/power/skill_invoke.py +165 -0
  70. opscloud/commands/power/tasks.py +209 -0
  71. opscloud/commands/power/trace.py +117 -0
  72. opscloud/commands/power/ui_toggles.py +135 -0
  73. opscloud/commands/power/version.py +52 -0
  74. opscloud/commands/registry.py +176 -0
  75. opscloud/config/__init__.py +114 -0
  76. opscloud/config/adapters/__init__.py +5 -0
  77. opscloud/config/adapters/postgres.py +776 -0
  78. opscloud/config/adapters/sqlite.py +769 -0
  79. opscloud/config/aws.py +248 -0
  80. opscloud/config/cloud_profiles.py +437 -0
  81. opscloud/config/env_vars.py +81 -0
  82. opscloud/config/langsmith.py +319 -0
  83. opscloud/config/manifest.py +817 -0
  84. opscloud/config/metadata.py +205 -0
  85. opscloud/config/paths.py +604 -0
  86. opscloud/config/plugins.py +130 -0
  87. opscloud/config/settings.py +834 -0
  88. opscloud/config/store.py +453 -0
  89. opscloud/config/store_factory.py +97 -0
  90. opscloud/config/toml_config.py +562 -0
  91. opscloud/editor.py +173 -0
  92. opscloud/exceptions.py +65 -0
  93. opscloud/file_ops.py +217 -0
  94. opscloud/hooks/__init__.py +139 -0
  95. opscloud/hooks/env.py +44 -0
  96. opscloud/hooks/interrupt.py +121 -0
  97. opscloud/hooks/models/__init__.py +109 -0
  98. opscloud/hooks/models/adapters.py +19 -0
  99. opscloud/hooks/models/config.py +79 -0
  100. opscloud/hooks/models/domain.py +434 -0
  101. opscloud/hooks/models/transport.py +37 -0
  102. opscloud/hooks/tools.py +231 -0
  103. opscloud/input.py +48 -0
  104. opscloud/integrations/__init__.py +80 -0
  105. opscloud/integrations/base.py +153 -0
  106. opscloud/integrations/content_filter.py +119 -0
  107. opscloud/integrations/event_bus.py +480 -0
  108. opscloud/integrations/hooks.py +238 -0
  109. opscloud/integrations/identity.py +181 -0
  110. opscloud/integrations/notifications.py +134 -0
  111. opscloud/integrations/stream_bridge.py +397 -0
  112. opscloud/integrations/thread_store.py +81 -0
  113. opscloud/json_types.py +20 -0
  114. opscloud/mcp/__init__.py +57 -0
  115. opscloud/mcp/config.py +203 -0
  116. opscloud/mcp/discovery.py +229 -0
  117. opscloud/mcp/mcp_info.py +109 -0
  118. opscloud/mcp/middleware.py +102 -0
  119. opscloud/mcp/preload.py +505 -0
  120. opscloud/mcp/raw_config.py +149 -0
  121. opscloud/mcp/semantic_profiler.py +305 -0
  122. opscloud/mcp/session_manager.py +727 -0
  123. opscloud/mcp/trust.py +105 -0
  124. opscloud/memory/__init__.py +27 -0
  125. opscloud/memory/branch.py +61 -0
  126. opscloud/memory/guard.py +19 -0
  127. opscloud/memory/onboarding.py +120 -0
  128. opscloud/memory/registry.py +134 -0
  129. opscloud/memory/store.py +223 -0
  130. opscloud/middleware/__init__.py +106 -0
  131. opscloud/middleware/_repository_bounds.py +459 -0
  132. opscloud/middleware/ask_user.py +152 -0
  133. opscloud/middleware/auto_mode.py +60 -0
  134. opscloud/middleware/auto_mode_hitl.py +2434 -0
  135. opscloud/middleware/compaction.py +566 -0
  136. opscloud/middleware/configurable_model.py +233 -0
  137. opscloud/middleware/cost_tracking.py +1021 -0
  138. opscloud/middleware/glm_stall_recovery.py +109 -0
  139. opscloud/middleware/goal_criteria.py +1234 -0
  140. opscloud/middleware/goal_state_notice.py +712 -0
  141. opscloud/middleware/goal_tools.py +407 -0
  142. opscloud/middleware/headless_mcp_guard.py +88 -0
  143. opscloud/middleware/jev_model_router.py +1018 -0
  144. opscloud/middleware/local_context.py +460 -0
  145. opscloud/middleware/mcp_context.py +151 -0
  146. opscloud/middleware/mcp_middleware.py +146 -0
  147. opscloud/middleware/memory_guard.py +393 -0
  148. opscloud/middleware/model_retry.py +205 -0
  149. opscloud/middleware/registry.py +137 -0
  150. opscloud/middleware/reliable_rubric.py +709 -0
  151. opscloud/middleware/resume_state.py +91 -0
  152. opscloud/middleware/server_hooks.py +1189 -0
  153. opscloud/middleware/shell_allow_list.py +90 -0
  154. opscloud/middleware/skills.py +382 -0
  155. opscloud/middleware/subagent_artifacts.py +84 -0
  156. opscloud/middleware/subagent_telemetry.py +261 -0
  157. opscloud/middleware/subagents.py +243 -0
  158. opscloud/middleware/tool_filter.py +384 -0
  159. opscloud/middleware/unified_system_message.py +62 -0
  160. opscloud/model/__init__.py +37 -0
  161. opscloud/model/config.py +2992 -0
  162. opscloud/model/factory.py +477 -0
  163. opscloud/model/pool.py +459 -0
  164. opscloud/model/reasoning.py +282 -0
  165. opscloud/offload.py +173 -0
  166. opscloud/output.py +64 -0
  167. opscloud/plugins/__init__.py +189 -0
  168. opscloud/plugins/_json.py +22 -0
  169. opscloud/plugins/adapters/__init__.py +13 -0
  170. opscloud/plugins/adapters/agents.py +112 -0
  171. opscloud/plugins/adapters/commands.py +190 -0
  172. opscloud/plugins/adapters/mcp.py +363 -0
  173. opscloud/plugins/adapters/skills.py +132 -0
  174. opscloud/plugins/commands_cli.py +541 -0
  175. opscloud/plugins/discovery.py +512 -0
  176. opscloud/plugins/manifest.py +335 -0
  177. opscloud/plugins/marketplace.py +893 -0
  178. opscloud/plugins/models.py +334 -0
  179. opscloud/plugins/project_plugins.py +317 -0
  180. opscloud/plugins/protocol.py +31 -0
  181. opscloud/plugins/store.py +905 -0
  182. opscloud/plugins/substitution.py +122 -0
  183. opscloud/project_utils.py +145 -0
  184. opscloud/prompts/__init__.py +37 -0
  185. opscloud/prompts/system.py +483 -0
  186. opscloud/prompts/templates/system_prompt.md +166 -0
  187. opscloud/prompts/types.py +54 -0
  188. opscloud/rubrics/__init__.py +42 -0
  189. opscloud/rubrics/evaluator.py +404 -0
  190. opscloud/rubrics/evidence_extractor.py +229 -0
  191. opscloud/rubrics/generator.py +239 -0
  192. opscloud/rubrics/jev_compiler.py +175 -0
  193. opscloud/rubrics/jev_grader.py +297 -0
  194. opscloud/rubrics/middleware.py +8 -0
  195. opscloud/schema/__init__.py +107 -0
  196. opscloud/schema/interrupts.py +981 -0
  197. opscloud/security/__init__.py +91 -0
  198. opscloud/security/approval_mode.py +528 -0
  199. opscloud/security/approval_mode_source.py +230 -0
  200. opscloud/security/cli_ast_evaluator.py +552 -0
  201. opscloud/security/jev_classifier.py +651 -0
  202. opscloud/security/shell_safety.py +340 -0
  203. opscloud/security/unicode_security.py +439 -0
  204. opscloud/security/url_validation.py +160 -0
  205. opscloud/server/__init__.py +13 -0
  206. opscloud/server/_server_config.py +158 -0
  207. opscloud/server/server.py +242 -0
  208. opscloud/server/server_graph.py +93 -0
  209. opscloud/skills/__init__.py +91 -0
  210. opscloud/skills/commands.py +1099 -0
  211. opscloud/skills/invocation.py +200 -0
  212. opscloud/skills/load.py +388 -0
  213. opscloud/skills/loader.py +23 -0
  214. opscloud/skills/merge.py +65 -0
  215. opscloud/skills/registry.py +263 -0
  216. opscloud/skills/sources.py +174 -0
  217. opscloud/skills/trust.py +417 -0
  218. opscloud/state/__init__.py +142 -0
  219. opscloud/state/base.py +154 -0
  220. opscloud/state/cloud_context.py +262 -0
  221. opscloud/state/goal_channels.py +124 -0
  222. opscloud/state/goal_state_limits.py +175 -0
  223. opscloud/state/resume_state.py +58 -0
  224. opscloud/state/service_context.py +281 -0
  225. opscloud/state/session.py +957 -0
  226. opscloud/state/state_migration.py +86 -0
  227. opscloud/subagents/__init__.py +30 -0
  228. opscloud/subagents/loader.py +331 -0
  229. opscloud/subagents/subagents_parser.py +117 -0
  230. opscloud/subagents/types.py +25 -0
  231. opscloud/tools/__init__.py +36 -0
  232. opscloud/tools/catalog.py +37 -0
  233. opscloud/tools/display.py +24 -0
  234. opscloud/tools/fetch_url.py +176 -0
  235. opscloud/tools/goal_tools.py +423 -0
  236. opscloud/tools/registry.py +105 -0
  237. opscloud/tools/thread.py +32 -0
  238. opscloud/tools/web_search.py +215 -0
  239. opscloud/ui/__init__.py +119 -0
  240. opscloud/ui/app.py +5400 -0
  241. opscloud/ui/app.tcss +479 -0
  242. opscloud/ui/clipboard.py +147 -0
  243. opscloud/ui/command_registry.py +482 -0
  244. opscloud/ui/message_store.py +152 -0
  245. opscloud/ui/permission_store.py +360 -0
  246. opscloud/ui/preferences.py +113 -0
  247. opscloud/ui/prompt_manager.py +96 -0
  248. opscloud/ui/remote_client.py +505 -0
  249. opscloud/ui/textual_adapter.py +1269 -0
  250. opscloud/ui/theme.py +728 -0
  251. opscloud/ui/ui_help.py +415 -0
  252. opscloud/ui/widgets/__init__.py +383 -0
  253. opscloud/ui/widgets/_ask_user_types.py +81 -0
  254. opscloud/ui/widgets/_copy_spans.py +52 -0
  255. opscloud/ui/widgets/_inline_prompt.py +290 -0
  256. opscloud/ui/widgets/_js_eval_display.py +139 -0
  257. opscloud/ui/widgets/_links.py +175 -0
  258. opscloud/ui/widgets/_tool_stream.py +308 -0
  259. opscloud/ui/widgets/agent_selector.py +297 -0
  260. opscloud/ui/widgets/approval.py +741 -0
  261. opscloud/ui/widgets/ask_user.py +539 -0
  262. opscloud/ui/widgets/auth.py +695 -0
  263. opscloud/ui/widgets/auth_manager.py +214 -0
  264. opscloud/ui/widgets/auto_mode_notice.py +146 -0
  265. opscloud/ui/widgets/autocomplete.py +709 -0
  266. opscloud/ui/widgets/chat_input.py +805 -0
  267. opscloud/ui/widgets/cloud_selector.py +528 -0
  268. opscloud/ui/widgets/config_manager.py +652 -0
  269. opscloud/ui/widgets/debug_console.py +1269 -0
  270. opscloud/ui/widgets/devops_renderers.py +184 -0
  271. opscloud/ui/widgets/diff.py +237 -0
  272. opscloud/ui/widgets/effort_selector.py +166 -0
  273. opscloud/ui/widgets/goal_review.py +612 -0
  274. opscloud/ui/widgets/goal_status.py +57 -0
  275. opscloud/ui/widgets/infra_panel.py +141 -0
  276. opscloud/ui/widgets/install_confirm.py +114 -0
  277. opscloud/ui/widgets/loading.py +170 -0
  278. opscloud/ui/widgets/mcp_viewer.py +1204 -0
  279. opscloud/ui/widgets/messages.py +4461 -0
  280. opscloud/ui/widgets/model_selector.py +863 -0
  281. opscloud/ui/widgets/notification_center.py +108 -0
  282. opscloud/ui/widgets/notification_settings.py +119 -0
  283. opscloud/ui/widgets/operation_card.py +106 -0
  284. opscloud/ui/widgets/permissions_manager.py +667 -0
  285. opscloud/ui/widgets/plugin_manager.py +1212 -0
  286. opscloud/ui/widgets/pool_selector.py +376 -0
  287. opscloud/ui/widgets/preamble.py +53 -0
  288. opscloud/ui/widgets/skills_viewer.py +432 -0
  289. opscloud/ui/widgets/status.py +1037 -0
  290. opscloud/ui/widgets/subagent_panel.py +927 -0
  291. opscloud/ui/widgets/theme_selector.py +232 -0
  292. opscloud/ui/widgets/thread_selector.py +358 -0
  293. opscloud/ui/widgets/toast.py +31 -0
  294. opscloud/ui/widgets/tool_display.py +325 -0
  295. opscloud/ui/widgets/tool_renderers.py +234 -0
  296. opscloud/ui/widgets/tool_widgets.py +278 -0
  297. opscloud/ui/widgets/welcome.py +421 -0
  298. opscloud/ui/widgets/welcome_popup.py +102 -0
  299. opscloud/utils/__init__.py +24 -0
  300. opscloud/utils/cost_estimation.py +169 -0
  301. opscloud/utils/git.py +390 -0
  302. opscloud/utils/logger.py +571 -0
  303. opscloud/utils/session_stats.py +115 -0
  304. opscloud/utils/startup_error.py +37 -0
  305. talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
  306. talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
  307. talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
  308. talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
  309. talkops_opscloud-0.3.0b1.dist-info/licenses/LICENSE +175 -0
@@ -0,0 +1,981 @@
1
+ """Typed interrupt and resume payload schemas for OpsCloud.
2
+
3
+ Provides deterministic, typed Pydantic models and TypedDicts for interrupt
4
+ requests and resumptions, aligned with official dcode contracts and terminal-native
5
+ (CLI and Textual TUI) operations.
6
+
7
+ Covers:
8
+ 1. Tool Approvals (HITL): HITLRequest, ActionRequest, ReviewConfig, HitlDecision,
9
+ ApproveDecision, RejectDecision, EditDecision, SwitchManualDecision, AutoApproveAllDecision, HitlResumePayload.
10
+ 2. Interactive Question Prompts: AskUserRequest, Question, Choice, ValidatedQuestion,
11
+ AskUserAnswered, AskUserCancelled, AskUserResumePayload, multi-select encoders/decoders,
12
+ and transcript rendering.
13
+ 3. Goal & Acceptance Criteria Review: GoalReviewRequest, GoalReviewResumePayload.
14
+ 4. Hook Invocation Interrupts: HookInvocationInterrupt, HookInvocationRequest, HookInvocationResponse.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from typing import (
21
+ Annotated,
22
+ Any,
23
+ Literal,
24
+ NotRequired,
25
+ Sequence,
26
+ assert_never,
27
+ get_args,
28
+ )
29
+
30
+ from pydantic import (
31
+ AfterValidator,
32
+ BaseModel,
33
+ ConfigDict,
34
+ Field,
35
+ TypeAdapter,
36
+ )
37
+ from typing_extensions import TypedDict
38
+
39
+ from opscloud.hooks.interrupt import (
40
+ HOOK_INVOCATION_INTERRUPT_TYPE,
41
+ HookInvocationInterrupt,
42
+ build_hook_interrupt_payload,
43
+ build_hook_resume_value,
44
+ is_hook_interrupt_payload,
45
+ parse_hook_interrupt_payload,
46
+ parse_hook_resume_value,
47
+ )
48
+ from opscloud.hooks.models.transport import (
49
+ HookInvocationRequest,
50
+ HookInvocationResponse,
51
+ )
52
+
53
+ # ─────────────────────────────────────────────────────────────────────────────
54
+ # 1. Normalization Vocabulary & Decision Tokens
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+
57
+ _CONFIRM_DECISIONS: frozenset[str] = frozenset(
58
+ {
59
+ "accept",
60
+ "confirm",
61
+ "accepted",
62
+ "confirmed",
63
+ "approve",
64
+ "approved",
65
+ "approval",
66
+ "proceed",
67
+ "continue",
68
+ "y",
69
+ "yes",
70
+ "ok",
71
+ "true",
72
+ "1",
73
+ "goal_response",
74
+ "hitl_response",
75
+ }
76
+ )
77
+
78
+ _SMART_DECISIONS: frozenset[str] = frozenset(
79
+ {
80
+ "smart_approve_all",
81
+ "enable_smart",
82
+ "smart",
83
+ "s",
84
+ }
85
+ )
86
+
87
+ _AUTO_DECISIONS: frozenset[str] = frozenset(
88
+ {
89
+ "auto_approve_all",
90
+ "enable_auto",
91
+ "auto",
92
+ "a",
93
+ "always",
94
+ "smart_approve_all",
95
+ "enable_smart",
96
+ "smart",
97
+ "s",
98
+ }
99
+ )
100
+
101
+ _EDIT_DECISIONS: frozenset[str] = frozenset(
102
+ {
103
+ "edit",
104
+ "edited",
105
+ "e",
106
+ "modify",
107
+ "modified",
108
+ "update",
109
+ "updated",
110
+ }
111
+ )
112
+
113
+ _SWITCH_MANUAL_DECISIONS: frozenset[str] = frozenset(
114
+ {
115
+ "switch_manual",
116
+ "manual",
117
+ "switch",
118
+ "m",
119
+ }
120
+ )
121
+
122
+ _REJECT_DECISIONS: frozenset[str] = frozenset(
123
+ {
124
+ "reject",
125
+ "rejected",
126
+ "r",
127
+ "deny",
128
+ "denied",
129
+ "disapprove",
130
+ "disapproved",
131
+ }
132
+ )
133
+
134
+ _CANCEL_DECISIONS: frozenset[str] = frozenset(
135
+ {
136
+ "cancel",
137
+ "cancelled",
138
+ "dismiss",
139
+ "dismissed",
140
+ "close",
141
+ "closed",
142
+ "n",
143
+ "no",
144
+ }
145
+ )
146
+
147
+
148
+ # ─────────────────────────────────────────────────────────────────────────────
149
+ # 2. HITL / Tool Approval Schemas
150
+ # ─────────────────────────────────────────────────────────────────────────────
151
+
152
+ class ActionRequest(TypedDict, total=False):
153
+ """An action request emitted when a tool call requires human review."""
154
+
155
+ name: str
156
+ args: dict[str, Any]
157
+ id: str
158
+
159
+
160
+ class ReviewConfig(TypedDict, total=False):
161
+ """Configuration governing review options for a specific action."""
162
+
163
+ action_name: str
164
+ allowed_decisions: list[str]
165
+
166
+
167
+ class HITLRequest(TypedDict, total=False):
168
+ """LangGraph interrupt payload when sensitive tools require approval."""
169
+
170
+ action_requests: list[ActionRequest]
171
+ review_configs: list[ReviewConfig]
172
+
173
+
174
+ class HitlDecision(BaseModel):
175
+ """Individual tool approval decision with support for args editing and manual switch."""
176
+
177
+ model_config = ConfigDict(extra="ignore")
178
+
179
+ type: Literal["approve", "reject", "edit", "switch_manual", "auto_approve_all", "smart_approve_all"] = "approve"
180
+ message: str | None = None
181
+ args: dict[str, Any] | None = None
182
+
183
+ def to_command_dict(self) -> dict[str, Any]:
184
+ """Convert to dictionary matching LangGraph resume expectation."""
185
+ res: dict[str, Any] = {"type": self.type}
186
+ if self.message is not None:
187
+ res["message"] = self.message
188
+ if self.args is not None:
189
+ res["args"] = self.args
190
+ return res
191
+
192
+
193
+ class ApproveDecision(BaseModel):
194
+ """Explicit approve decision."""
195
+
196
+ type: Literal["approve"] = "approve"
197
+
198
+
199
+ class RejectDecision(BaseModel):
200
+ """Explicit reject decision with optional reason."""
201
+
202
+ type: Literal["reject"] = "reject"
203
+ message: str | None = None
204
+
205
+
206
+ class EditDecision(BaseModel):
207
+ """Explicit edit decision with revised tool arguments."""
208
+
209
+ type: Literal["edit"] = "edit"
210
+ args: dict[str, Any] = Field(default_factory=dict)
211
+
212
+
213
+ class SwitchManualDecision(BaseModel):
214
+ """Switch from auto mode fallback to manual review."""
215
+
216
+ type: Literal["switch_manual"] = "switch_manual"
217
+
218
+
219
+ class AutoApproveAllDecision(BaseModel):
220
+ """Enable auto-approval for all tools in session."""
221
+
222
+ type: Literal["auto_approve_all"] = "auto_approve_all"
223
+
224
+
225
+ class SmartApproveAllDecision(BaseModel):
226
+ """Enable smart-approval (Jev protected) for all tools in session."""
227
+
228
+ type: Literal["smart_approve_all"] = "smart_approve_all"
229
+
230
+
231
+ class HitlResumePayload(BaseModel):
232
+ """Resume payload for HITL tool approval interrupts."""
233
+
234
+ decisions: list[HitlDecision] = Field(default_factory=list)
235
+ auto_approve_requested: bool = False
236
+ smart_approve_requested: bool = False
237
+
238
+ def to_command_value(self) -> dict[str, Any]:
239
+ """Serialize for LangGraph `Command(resume=...)`."""
240
+ return {
241
+ "decisions": [d.to_command_dict() for d in self.decisions]
242
+ }
243
+
244
+ @classmethod
245
+ def from_raw(
246
+ cls,
247
+ data: Any,
248
+ count: int = 1,
249
+ ) -> HitlResumePayload:
250
+ """Parse arbitrary client data (dict, list, string) into HitlResumePayload.
251
+
252
+ Normalizes terminal CLI options, TUI modals, and headless responses.
253
+ """
254
+ if isinstance(data, cls):
255
+ return data
256
+
257
+ parsed = data
258
+ if isinstance(data, str) and data.strip():
259
+ trimmed = data.strip()
260
+ if (trimmed.startswith("{") and trimmed.endswith("}")) or (
261
+ trimmed.startswith("[") and trimmed.endswith("]")
262
+ ):
263
+ try:
264
+ parsed = json.loads(trimmed)
265
+ except (json.JSONDecodeError, ValueError):
266
+ parsed = data
267
+
268
+ auto_mode = False
269
+ smart_mode = False
270
+ target_count = max(1, count)
271
+
272
+ if isinstance(parsed, dict):
273
+ # Check for decisions array from HITL response
274
+ decisions_raw = parsed.get("decisions")
275
+ if isinstance(decisions_raw, list) and decisions_raw:
276
+ decisions: list[HitlDecision] = []
277
+ for item in decisions_raw:
278
+ if isinstance(item, dict):
279
+ raw_t = (
280
+ str(
281
+ item.get("type") or item.get("decision") or "approve",
282
+ )
283
+ .lower()
284
+ .strip()
285
+ )
286
+ msg = item.get("message") or item.get("feedback")
287
+ edited_args = item.get("args") or item.get("edited_args") or item.get("parameters")
288
+
289
+ if raw_t in _SMART_DECISIONS:
290
+ smart_mode = True
291
+ auto_mode = True
292
+ dec_type: Literal["approve", "reject", "edit", "switch_manual", "auto_approve_all", "smart_approve_all"] = "approve"
293
+ elif raw_t in _AUTO_DECISIONS:
294
+ auto_mode = True
295
+ dec_type = "approve"
296
+ elif raw_t in _EDIT_DECISIONS:
297
+ dec_type = "edit"
298
+ elif raw_t in _SWITCH_MANUAL_DECISIONS:
299
+ dec_type = "switch_manual"
300
+ elif raw_t in _CONFIRM_DECISIONS:
301
+ dec_type = "approve"
302
+ else:
303
+ dec_type = "reject"
304
+
305
+ decisions.append(
306
+ HitlDecision(
307
+ type=dec_type,
308
+ message=str(msg) if msg else None,
309
+ args=edited_args if isinstance(edited_args, dict) else None,
310
+ ),
311
+ )
312
+ else:
313
+ s = str(item).lower().strip()
314
+ if s in _SMART_DECISIONS:
315
+ smart_mode = True
316
+ auto_mode = True
317
+ dec_type = "approve"
318
+ elif s in _AUTO_DECISIONS:
319
+ auto_mode = True
320
+ dec_type = "approve"
321
+ elif s in _EDIT_DECISIONS:
322
+ dec_type = "edit"
323
+ elif s in _SWITCH_MANUAL_DECISIONS:
324
+ dec_type = "switch_manual"
325
+ elif s in _CONFIRM_DECISIONS:
326
+ dec_type = "approve"
327
+ else:
328
+ dec_type = "reject"
329
+ decisions.append(HitlDecision(type=dec_type))
330
+
331
+ if decisions:
332
+ return cls(
333
+ decisions=decisions,
334
+ auto_approve_requested=auto_mode,
335
+ smart_approve_requested=smart_mode,
336
+ )
337
+
338
+ # Check single decision / action
339
+ raw_dec = (
340
+ parsed.get("decision")
341
+ or parsed.get("action")
342
+ or parsed.get("choice")
343
+ or parsed.get("type")
344
+ or parsed.get("status")
345
+ )
346
+ dec_str = str(raw_dec).lower().strip() if raw_dec is not None else ""
347
+ msg = parsed.get("message") or parsed.get("feedback") or parsed.get("rejectionReason")
348
+ msg_str = str(msg).strip() if msg else None
349
+ edited_args = parsed.get("args") or parsed.get("edited_args") or parsed.get("parameters")
350
+
351
+ if dec_str in _SMART_DECISIONS:
352
+ return cls(
353
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
354
+ auto_approve_requested=True,
355
+ smart_approve_requested=True,
356
+ )
357
+ if dec_str in _AUTO_DECISIONS:
358
+ return cls(
359
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
360
+ auto_approve_requested=True,
361
+ smart_approve_requested=False,
362
+ )
363
+ if dec_str in _EDIT_DECISIONS:
364
+ return cls(
365
+ decisions=[
366
+ HitlDecision(
367
+ type="edit",
368
+ message=msg_str,
369
+ args=edited_args if isinstance(edited_args, dict) else None,
370
+ )
371
+ for _ in range(target_count)
372
+ ],
373
+ auto_approve_requested=False,
374
+ smart_approve_requested=False,
375
+ )
376
+ if dec_str in _SWITCH_MANUAL_DECISIONS:
377
+ return cls(
378
+ decisions=[HitlDecision(type="switch_manual") for _ in range(target_count)],
379
+ auto_approve_requested=False,
380
+ smart_approve_requested=False,
381
+ )
382
+ if dec_str in _CONFIRM_DECISIONS:
383
+ return cls(
384
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
385
+ auto_approve_requested=False,
386
+ smart_approve_requested=False,
387
+ )
388
+ if dec_str in _REJECT_DECISIONS or dec_str in _CANCEL_DECISIONS:
389
+ return cls(
390
+ decisions=[HitlDecision(type="reject", message=msg_str) for _ in range(target_count)],
391
+ auto_approve_requested=False,
392
+ smart_approve_requested=False,
393
+ )
394
+ return cls(
395
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
396
+ auto_approve_requested=False,
397
+ smart_approve_requested=False,
398
+ )
399
+
400
+ if isinstance(parsed, list):
401
+ decisions = []
402
+ for item in parsed:
403
+ s = str(item).lower().strip()
404
+ if s in _SMART_DECISIONS:
405
+ smart_mode = True
406
+ auto_mode = True
407
+ dec_type = "approve"
408
+ elif s in _AUTO_DECISIONS:
409
+ auto_mode = True
410
+ dec_type = "approve"
411
+ elif s in _EDIT_DECISIONS:
412
+ dec_type = "edit"
413
+ elif s in _SWITCH_MANUAL_DECISIONS:
414
+ dec_type = "switch_manual"
415
+ elif s in _CONFIRM_DECISIONS:
416
+ dec_type = "approve"
417
+ else:
418
+ dec_type = "reject"
419
+ decisions.append(HitlDecision(type=dec_type))
420
+ return cls(
421
+ decisions=decisions or [HitlDecision(type="approve")],
422
+ auto_approve_requested=auto_mode,
423
+ smart_approve_requested=smart_mode,
424
+ )
425
+
426
+ if isinstance(parsed, str):
427
+ s = parsed.lower().strip()
428
+ if s in _SMART_DECISIONS:
429
+ return cls(
430
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
431
+ auto_approve_requested=True,
432
+ smart_approve_requested=True,
433
+ )
434
+ if s in _AUTO_DECISIONS:
435
+ return cls(
436
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
437
+ auto_approve_requested=True,
438
+ smart_approve_requested=False,
439
+ )
440
+ if s in _EDIT_DECISIONS:
441
+ return cls(
442
+ decisions=[HitlDecision(type="edit") for _ in range(target_count)],
443
+ auto_approve_requested=False,
444
+ smart_approve_requested=False,
445
+ )
446
+ if s in _SWITCH_MANUAL_DECISIONS:
447
+ return cls(
448
+ decisions=[HitlDecision(type="switch_manual") for _ in range(target_count)],
449
+ auto_approve_requested=False,
450
+ smart_approve_requested=False,
451
+ )
452
+ if s in _CONFIRM_DECISIONS:
453
+ return cls(
454
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
455
+ auto_approve_requested=False,
456
+ smart_approve_requested=False,
457
+ )
458
+ if s in _REJECT_DECISIONS or s in _CANCEL_DECISIONS:
459
+ return cls(
460
+ decisions=[HitlDecision(type="reject") for _ in range(target_count)],
461
+ auto_approve_requested=False,
462
+ smart_approve_requested=False,
463
+ )
464
+ return cls(
465
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
466
+ auto_approve_requested=False,
467
+ smart_approve_requested=False,
468
+ )
469
+
470
+ return cls(
471
+ decisions=[HitlDecision(type="approve") for _ in range(target_count)],
472
+ auto_approve_requested=False,
473
+ smart_approve_requested=False,
474
+ )
475
+
476
+
477
+ # ─────────────────────────────────────────────────────────────────────────────
478
+ # 3. Interactive Question Prompts (ask_user) Schemas & Encoders
479
+ # ─────────────────────────────────────────────────────────────────────────────
480
+
481
+ QuestionType = Literal["text", "multiple_choice", "multi_select"]
482
+ """Supported `ask_user` question types."""
483
+
484
+ QUESTION_TYPES: frozenset[str] = frozenset(get_args(QuestionType))
485
+
486
+
487
+ def _requires_choices(question_type: QuestionType) -> bool:
488
+ """Return whether question_type needs a non-empty choices list."""
489
+ if question_type == "text":
490
+ return False
491
+ if question_type == "multiple_choice" or question_type == "multi_select":
492
+ return True
493
+ assert_never(question_type)
494
+
495
+
496
+ CHOICE_QUESTION_TYPES: frozenset[str] = frozenset(
497
+ qt for qt in get_args(QuestionType) if _requires_choices(qt)
498
+ )
499
+
500
+
501
+ def encode_multi_select_answer(values: list[str]) -> str:
502
+ """Encode selected values of a multi_select answer into a JSON string.
503
+
504
+ Keeps exactly one slot per question on the wire.
505
+ """
506
+ return json.dumps(values, ensure_ascii=False)
507
+
508
+
509
+ def decode_multi_select_answer(raw: str) -> list[str] | None:
510
+ """Decode a multi_select answer produced by encode_multi_select_answer."""
511
+ try:
512
+ decoded = json.loads(raw)
513
+ except (json.JSONDecodeError, RecursionError):
514
+ return None
515
+ if not isinstance(decoded, list) or not all(isinstance(v, str) for v in decoded):
516
+ return None
517
+ return decoded
518
+
519
+
520
+ def ask_user_answer_is_empty(answer: str, question_type: object) -> bool:
521
+ """Return whether an ask_user answer counts as empty/unanswered."""
522
+ if question_type == "multi_select":
523
+ return not decode_multi_select_answer(answer)
524
+ return not answer.strip()
525
+
526
+
527
+ def _validate_question_text(text: str) -> str:
528
+ if not text.strip():
529
+ raise ValueError("question text must not be blank")
530
+ return text
531
+
532
+
533
+ class Choice(TypedDict):
534
+ """A single choice option for a multiple choice or multi-select question."""
535
+
536
+ value: Annotated[str, Field(description="The display label for this choice.")]
537
+
538
+
539
+ def _validate_choice(choice: Choice) -> Choice:
540
+ if not choice.get("value", "").strip():
541
+ raise ValueError(f"choice has a blank 'value': {choice!r}")
542
+ return choice
543
+
544
+
545
+ def _validate_question(question: Question) -> Question:
546
+ q_type = question.get("type", "text")
547
+ q_text = question.get("question", "")
548
+ choices = question.get("choices")
549
+ if q_type in CHOICE_QUESTION_TYPES:
550
+ if not choices:
551
+ raise ValueError(f"{q_type} question {q_text!r} requires a non-empty 'choices' list")
552
+ elif choices:
553
+ raise ValueError(f"{q_type} question {q_text!r} must not define 'choices'")
554
+ return question
555
+
556
+
557
+ class Question(TypedDict):
558
+ """A question to ask the user in terminal CLI or TUI."""
559
+
560
+ question: Annotated[
561
+ str,
562
+ AfterValidator(_validate_question_text),
563
+ Field(description="The question text to display.", min_length=1),
564
+ ]
565
+ type: Annotated[
566
+ QuestionType,
567
+ Field(
568
+ description=(
569
+ "Question type. 'text' for free-form input, 'multiple_choice' for "
570
+ "picking exactly one option, 'multi_select' for picking one or more options."
571
+ )
572
+ ),
573
+ ]
574
+ choices: NotRequired[
575
+ Annotated[
576
+ list[Annotated[Choice, AfterValidator(_validate_choice)]],
577
+ Field(description="Options for multiple_choice and multi_select questions."),
578
+ ]
579
+ ]
580
+ required: NotRequired[
581
+ Annotated[
582
+ bool,
583
+ Field(description="Whether the user must answer. Defaults to true if omitted."),
584
+ ]
585
+ ]
586
+
587
+
588
+ ValidatedQuestion = Annotated[Question, AfterValidator(_validate_question)]
589
+
590
+
591
+ def _validate_questions(questions: list[ValidatedQuestion]) -> list[ValidatedQuestion]:
592
+ if not questions:
593
+ raise ValueError("ask_user requires at least one question")
594
+ return questions
595
+
596
+
597
+ class AskUserRequest(TypedDict):
598
+ """Request payload sent via interrupt when asking the user questions."""
599
+
600
+ type: Literal["ask_user"]
601
+ questions: Annotated[list[ValidatedQuestion], AfterValidator(_validate_questions)]
602
+ tool_call_id: str
603
+
604
+
605
+ class AskUserAnswered(TypedDict):
606
+ """Widget result when the user submits answers."""
607
+
608
+ type: Literal["answered"]
609
+ answers: list[str]
610
+
611
+
612
+ class AskUserCancelled(TypedDict):
613
+ """Widget result when the user cancels the prompt."""
614
+
615
+ type: Literal["cancelled"]
616
+
617
+
618
+ AskUserWidgetResult = AskUserAnswered | AskUserCancelled
619
+
620
+
621
+ ASK_USER_NOTHING_SELECTED = "(nothing selected)"
622
+ ASK_USER_NO_ANSWER = "(no answer)"
623
+ ASK_USER_CANCELLED_ANSWER = "(cancelled)"
624
+ ASK_USER_ERROR_ANSWER_PREFIX = "(error: "
625
+ ASK_USER_ANSWERED_SUMMARY = "User answered"
626
+ ASK_USER_CANCELLED_SUMMARY = "Question cancelled"
627
+ ASK_USER_FAILED_SUMMARY = "Question failed"
628
+
629
+
630
+ def format_ask_user_error_answer(detail: str) -> str:
631
+ """Render the placeholder answer recorded for every question on failure."""
632
+ return f"{ASK_USER_ERROR_ANSWER_PREFIX}{detail})"
633
+
634
+
635
+ def format_ask_user_transcript(
636
+ questions: Sequence[Question | dict[str, Any]],
637
+ answers: Sequence[str],
638
+ ) -> str:
639
+ """Render questions and answers as blank-line separated Q:/A: blocks."""
640
+ blocks = [
641
+ f"Q: {q.get('question', '')}\n"
642
+ f"A: {answers[i] if i < len(answers) else ASK_USER_NO_ANSWER}"
643
+ for i, q in enumerate(questions)
644
+ ]
645
+ return "\n\n".join(blocks)
646
+
647
+
648
+ def render_ask_user_transcript_for_display(
649
+ questions: list[Question | dict[str, Any]],
650
+ transcript: str,
651
+ ) -> str | None:
652
+ """Re-render transcript with multi_select answers unpacked into human-readable lines."""
653
+ if not questions:
654
+ return None
655
+ anchors = [f"Q: {q.get('question', '')}\nA: " for q in questions]
656
+ answers: list[str] = []
657
+ position = 0
658
+ for index, anchor in enumerate(anchors):
659
+ if not transcript.startswith(anchor, position):
660
+ return None
661
+ position += len(anchor)
662
+ if index + 1 == len(anchors):
663
+ answers.append(transcript[position:])
664
+ break
665
+ separator = f"\n\n{anchors[index + 1]}"
666
+ end = transcript.find(separator, position)
667
+ if end == -1 or transcript.find(separator, end + 2) != -1:
668
+ return None
669
+ answers.append(transcript[position:end])
670
+ position = end + 2
671
+
672
+ changed = False
673
+ blocks: list[str] = []
674
+ for q, ans in zip(questions, answers, strict=True):
675
+ rendered = ans
676
+ if q.get("type") == "multi_select":
677
+ values = decode_multi_select_answer(ans)
678
+ if values is not None:
679
+ rendered = "\n".join(values) if values else ASK_USER_NOTHING_SELECTED
680
+ changed = changed or rendered != ans
681
+ blocks.append(f"Q: {q.get('question', '')}\nA: {rendered}")
682
+ if not changed:
683
+ return None
684
+ return "\n\n".join(blocks)
685
+
686
+
687
+ class AskUserResumePayload(BaseModel):
688
+ """Resume payload for ask_user question prompts."""
689
+
690
+ status: Literal["answered", "cancelled", "error"] = "answered"
691
+ answers: list[str] = Field(default_factory=list)
692
+ error: str | None = None
693
+
694
+ def to_command_value(self) -> dict[str, Any]:
695
+ """Serialize for LangGraph `Command(resume=...)`."""
696
+ res: dict[str, Any] = {
697
+ "status": self.status,
698
+ "answers": self.answers,
699
+ }
700
+ if self.error:
701
+ res["error"] = self.error
702
+ return res
703
+
704
+ @classmethod
705
+ def from_raw(
706
+ cls,
707
+ data: Any,
708
+ questions: list[Any] | None = None,
709
+ ) -> AskUserResumePayload:
710
+ """Parse arbitrary client data into AskUserResumePayload."""
711
+ if isinstance(data, cls):
712
+ return data
713
+
714
+ q_count = len(questions) if questions else 1
715
+ parsed = data
716
+ if isinstance(data, str) and data.strip():
717
+ trimmed = data.strip()
718
+ if (trimmed.startswith("{") and trimmed.endswith("}")) or (
719
+ trimmed.startswith("[") and trimmed.endswith("]")
720
+ ):
721
+ try:
722
+ parsed = json.loads(trimmed)
723
+ except (json.JSONDecodeError, ValueError):
724
+ parsed = data
725
+
726
+ if isinstance(parsed, dict):
727
+ status = str(parsed.get("status") or parsed.get("type", "answered")).lower().strip()
728
+ err_msg = parsed.get("error")
729
+
730
+ if status == "cancelled":
731
+ return cls(
732
+ status="cancelled",
733
+ answers=[ASK_USER_CANCELLED_ANSWER for _ in range(q_count)],
734
+ )
735
+ if status == "error":
736
+ return cls(
737
+ status="error",
738
+ error=str(err_msg) if err_msg else "unknown error",
739
+ answers=[format_ask_user_error_answer(str(err_msg or "error")) for _ in range(q_count)],
740
+ )
741
+
742
+ if "answers" in parsed:
743
+ raw_ans = parsed["answers"]
744
+ ans_list = [str(x) for x in raw_ans] if isinstance(raw_ans, list) else [str(raw_ans)]
745
+ return cls(status="answered", answers=ans_list)
746
+
747
+ if "choice" in parsed:
748
+ return cls(status="answered", answers=[str(parsed["choice"])])
749
+ if "text" in parsed:
750
+ return cls(status="answered", answers=[str(parsed["text"])])
751
+ if "answer" in parsed:
752
+ return cls(status="answered", answers=[str(parsed["answer"])])
753
+
754
+ return cls(status="answered", answers=[json.dumps(parsed)])
755
+
756
+ if isinstance(parsed, list):
757
+ return cls(status="answered", answers=[str(x) for x in parsed])
758
+
759
+ if isinstance(parsed, str):
760
+ s = parsed.strip()
761
+ if s.lower() in ("cancel", "cancelled"):
762
+ return cls(
763
+ status="cancelled",
764
+ answers=[ASK_USER_CANCELLED_ANSWER for _ in range(q_count)],
765
+ )
766
+ return cls(status="answered", answers=[s])
767
+
768
+ return cls(
769
+ status="answered",
770
+ answers=[str(parsed) if parsed is not None else ""],
771
+ )
772
+
773
+
774
+ # ─────────────────────────────────────────────────────────────────────────────
775
+ # 4. Goal, Criteria & Rubric Review Schemas
776
+ # ─────────────────────────────────────────────────────────────────────────────
777
+
778
+ class GoalReviewRequest(TypedDict, total=False):
779
+ """Request payload for human verification of proposed goals and criteria."""
780
+
781
+ type: Literal["goal_review"]
782
+ goal: str
783
+ criteria: list[str]
784
+ rubric: str | None
785
+
786
+
787
+ class GoalReviewResumePayload(BaseModel):
788
+ """Resume payload for goal_review / propose_goal interrupts."""
789
+
790
+ decision: Literal["confirm", "edit", "reject", "cancel"] = "confirm"
791
+ criteria: list[str] | None = None
792
+ feedback: str | None = None
793
+
794
+ def to_command_value(self) -> dict[str, Any]:
795
+ """Serialize for LangGraph `Command(resume=...)`."""
796
+ res: dict[str, Any] = {"decision": self.decision}
797
+ if self.criteria is not None:
798
+ res["criteria"] = self.criteria
799
+ if self.feedback is not None:
800
+ res["feedback"] = self.feedback
801
+ return res
802
+
803
+ @classmethod
804
+ def from_raw(
805
+ cls,
806
+ data: Any,
807
+ ) -> GoalReviewResumePayload:
808
+ """Parse arbitrary client data into GoalReviewResumePayload."""
809
+ if isinstance(data, cls):
810
+ return data
811
+
812
+ parsed = data
813
+ if isinstance(data, str) and data.strip():
814
+ trimmed = data.strip()
815
+ if (trimmed.startswith("{") and trimmed.endswith("}")) or (
816
+ trimmed.startswith("[") and trimmed.endswith("]")
817
+ ):
818
+ try:
819
+ parsed = json.loads(trimmed)
820
+ except (json.JSONDecodeError, ValueError):
821
+ parsed = data
822
+
823
+ if isinstance(parsed, dict):
824
+ # Unwrap nested dict if wrapped by interrupt ID
825
+ if len(parsed) == 1:
826
+ key = next(iter(parsed))
827
+ if key not in (
828
+ "decision",
829
+ "decisions",
830
+ "action",
831
+ "status",
832
+ "feedback",
833
+ "criteria",
834
+ "goalText",
835
+ "objective",
836
+ "choice",
837
+ "message",
838
+ "type",
839
+ ):
840
+ inner = parsed[key]
841
+ if isinstance(inner, dict):
842
+ parsed = inner
843
+ elif isinstance(inner, str):
844
+ try:
845
+ parsed = json.loads(inner)
846
+ except (json.JSONDecodeError, ValueError):
847
+ parsed = {"decision": inner}
848
+
849
+ # Check decisions list from HITL
850
+ decisions_val = parsed.get("decisions")
851
+ answers_val = parsed.get("answers")
852
+ if isinstance(decisions_val, list) and decisions_val:
853
+ first = decisions_val[0]
854
+ if isinstance(first, dict):
855
+ raw_dec = first.get("type") or first.get("decision") or "confirm"
856
+ feedback = first.get("message") or first.get("feedback")
857
+ else:
858
+ raw_dec = str(first)
859
+ feedback = None
860
+ elif isinstance(answers_val, list) and answers_val:
861
+ raw_dec = answers_val[0]
862
+ feedback = None
863
+ else:
864
+ raw_dec = (
865
+ parsed.get("decision")
866
+ or parsed.get("action")
867
+ or parsed.get("choice")
868
+ or parsed.get("type")
869
+ or parsed.get("status")
870
+ or "confirm"
871
+ )
872
+ feedback = parsed.get("feedback") or parsed.get("rejectionReason") or parsed.get("message")
873
+
874
+ raw_str = str(raw_dec).lower().strip()
875
+ fb_str = str(feedback).strip() if feedback else None
876
+
877
+ # Extract criteria if present
878
+ criteria_list: list[str] | None = None
879
+ crit_val = parsed.get("criteria")
880
+ if isinstance(crit_val, list):
881
+ criteria_list = [str(x).strip() for x in crit_val if str(x).strip()]
882
+ elif isinstance(crit_val, str) and crit_val.strip():
883
+ criteria_list = [
884
+ line.strip().lstrip("-*•0123456789.) ").strip()
885
+ for line in crit_val.splitlines()
886
+ if line.strip().lstrip("-*•0123456789.) ").strip()
887
+ ]
888
+
889
+ if raw_str in _EDIT_DECISIONS:
890
+ return cls(
891
+ decision="edit",
892
+ criteria=criteria_list,
893
+ feedback=fb_str,
894
+ )
895
+ if raw_str in _REJECT_DECISIONS or (fb_str and raw_str not in _CONFIRM_DECISIONS):
896
+ return cls(
897
+ decision="reject",
898
+ criteria=criteria_list,
899
+ feedback=fb_str,
900
+ )
901
+ if raw_str in _CANCEL_DECISIONS:
902
+ return cls(
903
+ decision="cancel",
904
+ criteria=criteria_list,
905
+ feedback=fb_str,
906
+ )
907
+ return cls(
908
+ decision="confirm",
909
+ criteria=criteria_list,
910
+ feedback=fb_str,
911
+ )
912
+
913
+ if isinstance(parsed, str):
914
+ s = parsed.lower().strip()
915
+ if s in _EDIT_DECISIONS:
916
+ return cls(decision="edit")
917
+ if s in _REJECT_DECISIONS:
918
+ return cls(decision="reject")
919
+ if s in _CANCEL_DECISIONS:
920
+ return cls(decision="cancel")
921
+ return cls(decision="confirm")
922
+
923
+ return cls(decision="confirm")
924
+
925
+
926
+ # ─────────────────────────────────────────────────────────────────────────────
927
+ # 5. Export Definitions
928
+ # ─────────────────────────────────────────────────────────────────────────────
929
+
930
+ __all__ = [
931
+ # HITL & Tool Approval
932
+ "ActionRequest",
933
+ "ApproveDecision",
934
+ "AutoApproveAllDecision",
935
+ "EditDecision",
936
+ "HITLRequest",
937
+ "HitlDecision",
938
+ "HitlResumePayload",
939
+ "RejectDecision",
940
+ "ReviewConfig",
941
+ "SmartApproveAllDecision",
942
+ "SwitchManualDecision",
943
+ # Ask User Protocol
944
+ "ASK_USER_ANSWERED_SUMMARY",
945
+ "ASK_USER_CANCELLED_ANSWER",
946
+ "ASK_USER_CANCELLED_SUMMARY",
947
+ "ASK_USER_ERROR_ANSWER_PREFIX",
948
+ "ASK_USER_FAILED_SUMMARY",
949
+ "ASK_USER_NOTHING_SELECTED",
950
+ "ASK_USER_NO_ANSWER",
951
+ "AskUserAnswered",
952
+ "AskUserCancelled",
953
+ "AskUserRequest",
954
+ "AskUserResumePayload",
955
+ "AskUserWidgetResult",
956
+ "CHOICE_QUESTION_TYPES",
957
+ "Choice",
958
+ "QUESTION_TYPES",
959
+ "Question",
960
+ "QuestionType",
961
+ "ValidatedQuestion",
962
+ "ask_user_answer_is_empty",
963
+ "decode_multi_select_answer",
964
+ "encode_multi_select_answer",
965
+ "format_ask_user_error_answer",
966
+ "format_ask_user_transcript",
967
+ "render_ask_user_transcript_for_display",
968
+ # Goal & Criteria Review
969
+ "GoalReviewRequest",
970
+ "GoalReviewResumePayload",
971
+ # Hook Invocation Interrupts
972
+ "HOOK_INVOCATION_INTERRUPT_TYPE",
973
+ "HookInvocationInterrupt",
974
+ "HookInvocationRequest",
975
+ "HookInvocationResponse",
976
+ "build_hook_interrupt_payload",
977
+ "build_hook_resume_value",
978
+ "is_hook_interrupt_payload",
979
+ "parse_hook_interrupt_payload",
980
+ "parse_hook_resume_value",
981
+ ]