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,397 @@
1
+ """Bridge between LangGraph graph execution and terminal/external stream sinks.
2
+
3
+ Uses ``stream_events(version="v3")`` / Pregel streams with model-agnostic
4
+ event translation and thinking/reasoning extraction across all major LLM
5
+ providers (Gemini, Anthropic, OpenAI, DeepSeek, and inline XML).
6
+
7
+ Output rendering is handled by the :class:`StreamSink` protocol.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import AsyncIterator
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ from langchain_core.messages import (
16
+ AIMessageChunk,
17
+ ToolMessage,
18
+ )
19
+
20
+ from opscloud.middleware.goal_state_notice import is_conversation_control_message
21
+ from opscloud.utils.logger import AgentLogger
22
+
23
+ logger = AgentLogger("StreamBridge")
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Tool Name Formatting
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def _humanize_tool_name(raw_name: str) -> str:
32
+ """Convert ``snake_case`` tool name to *Title Case* display name."""
33
+ if not raw_name:
34
+ return "Tool"
35
+ name = raw_name
36
+ for prefix in (
37
+ "transfer_to_",
38
+ "aws_",
39
+ "terraform_",
40
+ "tofu_",
41
+ "cloud_",
42
+ "kubernetes_",
43
+ "kubectl_",
44
+ "k8s_",
45
+ "helm_",
46
+ ):
47
+ if name.startswith(prefix):
48
+ name = name[len(prefix) :]
49
+ break
50
+ return name.replace("_", " ").strip().title() or raw_name.title()
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Model-Agnostic Thinking & Text Extraction
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def _extract_thinking_value(val: Any) -> list[str]:
59
+ """Recursively extract thinking text snippets from strings, lists, or dicts."""
60
+ parts: list[str] = []
61
+ if not val:
62
+ return parts
63
+ if isinstance(val, str):
64
+ parts.append(val)
65
+ elif isinstance(val, dict):
66
+ for k in ("thinking", "thought", "thoughts", "reasoning", "reasoning_content", "text", "summary", "content"):
67
+ sub = val.get(k)
68
+ if isinstance(sub, str) and sub:
69
+ parts.append(sub)
70
+ elif isinstance(sub, (list, dict)):
71
+ parts.extend(_extract_thinking_value(sub))
72
+ elif isinstance(val, list):
73
+ for item in val:
74
+ parts.extend(_extract_thinking_value(item))
75
+ return parts
76
+
77
+
78
+ def _extract_text_and_thinking(
79
+ content: Any,
80
+ additional_kwargs: dict[str, Any] | None = None,
81
+ response_metadata: dict[str, Any] | None = None,
82
+ msg_obj: Any = None,
83
+ ) -> tuple[str, str]:
84
+ """Extract regular response text and internal thinking/reasoning text.
85
+
86
+ Supports:
87
+ - DeepSeek: ``reasoning_content`` attribute
88
+ - Anthropic: ``thinking`` attribute or content blocks
89
+ - Gemini: ``thoughts`` / ``thinking`` in metadata or parts
90
+ - OpenAI: ``additional_kwargs["reasoning"]`` or ``additional_kwargs["thinking"]``
91
+ - Inline XML: ``<thinking>...</thinking>`` or ``<thought>...</thought>``
92
+
93
+ Returns:
94
+ tuple[str, str]: (text, thinking_text)
95
+ """
96
+ text_parts: list[str] = []
97
+ thinking_parts: list[str] = []
98
+
99
+ # 1. Direct reasoning attributes on message object
100
+ if msg_obj is not None:
101
+ direct_thinking = (
102
+ getattr(msg_obj, "reasoning_content", None)
103
+ or getattr(msg_obj, "thinking", None)
104
+ or getattr(msg_obj, "thoughts", None)
105
+ or getattr(msg_obj, "reasoning", None)
106
+ )
107
+ if direct_thinking:
108
+ thinking_parts.extend(_extract_thinking_value(direct_thinking))
109
+
110
+ # 2. Additional kwargs and response metadata
111
+ combined: dict[str, Any] = {}
112
+ if additional_kwargs and isinstance(additional_kwargs, dict):
113
+ combined.update(additional_kwargs)
114
+ if response_metadata and isinstance(response_metadata, dict):
115
+ combined.update(response_metadata)
116
+
117
+ thinking = (
118
+ combined.get("thinking")
119
+ or combined.get("thoughts")
120
+ or combined.get("reasoning_content")
121
+ or combined.get("thought")
122
+ or combined.get("reasoning")
123
+ )
124
+ if thinking:
125
+ thinking_parts.extend(_extract_thinking_value(thinking))
126
+
127
+ # 3. Content blocks & string parsing
128
+ if isinstance(content, str):
129
+ if "<thinking>" in content or "<thought>" in content:
130
+ raw = content
131
+ for tag_open, tag_close in [
132
+ ("<thinking>", "</thinking>"),
133
+ ("<thought>", "</thought>"),
134
+ ]:
135
+ while tag_open in raw:
136
+ start = raw.find(tag_open)
137
+ end = raw.find(tag_close, start)
138
+ if end != -1:
139
+ think_chunk = raw[start + len(tag_open) : end]
140
+ thinking_parts.append(think_chunk)
141
+ raw = raw[:start] + raw[end + len(tag_close) :]
142
+ else:
143
+ think_chunk = raw[start + len(tag_open) :]
144
+ thinking_parts.append(think_chunk)
145
+ raw = raw[:start]
146
+ if raw:
147
+ text_parts.append(raw)
148
+ elif content:
149
+ text_parts.append(content)
150
+ elif isinstance(content, list):
151
+ for block in content:
152
+ if isinstance(block, str):
153
+ text_parts.append(block)
154
+ elif isinstance(block, dict):
155
+ block_type = block.get("type", "")
156
+ think_val = (
157
+ block.get("thinking")
158
+ or block.get("thoughts")
159
+ or block.get("reasoning")
160
+ or block.get("reasoning_content")
161
+ )
162
+ if think_val:
163
+ thinking_parts.extend(_extract_thinking_value(think_val))
164
+ elif block_type in ("thinking", "thought", "reasoning"):
165
+ text_val = block.get("text") or block.get("thinking") or block.get("thought") or block.get("summary")
166
+ if text_val:
167
+ thinking_parts.extend(_extract_thinking_value(text_val))
168
+ elif isinstance(block.get("text"), str):
169
+ text_parts.append(block["text"])
170
+ elif hasattr(block, "text") and isinstance(block.text, str):
171
+ text_parts.append(block.text)
172
+ elif content is not None:
173
+ text_parts.append(str(content))
174
+
175
+ return "".join(text_parts), "".join(thinking_parts)
176
+
177
+
178
+ def _extract_text(
179
+ content: Any,
180
+ additional_kwargs: dict[str, Any] | None = None,
181
+ response_metadata: dict[str, Any] | None = None,
182
+ msg_obj: Any = None,
183
+ ) -> str:
184
+ """Extract string text with optional formatted thinking prefix."""
185
+ text, thinking = _extract_text_and_thinking(content, additional_kwargs, response_metadata, msg_obj)
186
+ if thinking:
187
+ return f"> *Thinking:* {thinking}\n\n{text}"
188
+ return text
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # StreamSink protocol
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ @runtime_checkable
197
+ class StreamSink(Protocol):
198
+ """Protocol for platform-specific stream output handlers."""
199
+
200
+ async def on_stream_start(
201
+ self,
202
+ channel_id: str,
203
+ thread_id: str,
204
+ ) -> None:
205
+ """Called once when a new streaming response begins."""
206
+ ...
207
+
208
+ async def on_token(self, token: str) -> None:
209
+ """Called for each LLM output token."""
210
+ ...
211
+
212
+ async def on_thinking(self, text: str) -> None:
213
+ """Called for LLM thinking/reasoning blocks."""
214
+ ...
215
+
216
+ async def on_tool_call_started(
217
+ self,
218
+ name: str,
219
+ call_id: str,
220
+ args: dict[str, Any],
221
+ ) -> None:
222
+ """Called when a tool execution begins."""
223
+ ...
224
+
225
+ async def on_tool_call_completed(
226
+ self,
227
+ name: str,
228
+ call_id: str,
229
+ result: str,
230
+ ) -> None:
231
+ """Called when a tool execution finishes."""
232
+ ...
233
+
234
+ async def on_interrupt(self, interrupt_value: dict[str, Any]) -> None:
235
+ """Called when the graph hits an interrupt (HITL / Ask User)."""
236
+ ...
237
+
238
+ async def on_subagent_event(self, agent_name: str, status: str) -> None:
239
+ """Called on subagent start/stop/progress."""
240
+ ...
241
+
242
+ async def on_rubric_event(self, data: dict[str, Any]) -> None:
243
+ """Called on rubric evaluation start/progress/complete."""
244
+ ...
245
+
246
+ async def on_auto_mode_event(self, data: dict[str, Any]) -> None:
247
+ """Called on auto-mode HITL classification events."""
248
+ ...
249
+
250
+ async def on_stream_end(self) -> None:
251
+ """Called when the graph run completes."""
252
+ ...
253
+
254
+ async def on_error(self, error: Exception) -> None:
255
+ """Called when execution raises an exception."""
256
+ ...
257
+
258
+ async def on_pre_interrupt(self) -> None:
259
+ """Called just before on_interrupt."""
260
+ ...
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # GraphStreamBridge
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ class GraphStreamBridge:
269
+ """Model-agnostic stream bridge between LangGraph runtime and StreamSink."""
270
+
271
+ def __init__(self, sink: StreamSink) -> None:
272
+ """Initialize GraphStreamBridge with the target stream sink.
273
+
274
+ Args:
275
+ sink: Output stream sink to receive parsed events.
276
+ """
277
+ self.sink = sink
278
+ self._buffered_text: list[str] = []
279
+ self._buffered_thinking: list[str] = []
280
+
281
+ @property
282
+ def buffered_text(self) -> str:
283
+ """Return all text emitted so far."""
284
+ return "".join(self._buffered_text)
285
+
286
+ @property
287
+ def buffered_thinking(self) -> str:
288
+ """Return all thinking emitted so far."""
289
+ return "".join(self._buffered_thinking)
290
+
291
+ async def process_stream(
292
+ self,
293
+ stream: AsyncIterator[tuple[str, Any]],
294
+ channel_id: str = "default",
295
+ thread_id: str = "default",
296
+ ) -> str:
297
+ """Consume LangGraph stream events and dispatch to StreamSink.
298
+
299
+ Handles stream modes:
300
+ - ``messages``: (AIMessageChunk, metadata)
301
+ - ``updates``: state updates, interrupts
302
+ - ``custom``: subagents, rubrics, auto-mode events
303
+
304
+ Returns:
305
+ The complete accumulated response text.
306
+ """
307
+ await self.sink.on_stream_start(channel_id, thread_id)
308
+ self._buffered_text.clear()
309
+ self._buffered_thinking.clear()
310
+
311
+ try:
312
+ async for mode, payload in stream:
313
+ if mode == "messages":
314
+ await self._handle_message_chunk(payload)
315
+ elif mode == "updates":
316
+ await self._handle_updates(payload)
317
+ elif mode == "custom":
318
+ await self._handle_custom(payload)
319
+ await self.sink.on_stream_end()
320
+ except Exception as exc:
321
+ logger.error(f"Error during stream processing: {exc}", exc_info=True)
322
+ await self.sink.on_error(exc)
323
+ raise
324
+
325
+ return self.buffered_text
326
+
327
+ async def _handle_message_chunk(self, chunk_tuple: Any) -> None:
328
+ """Process an AIMessageChunk or ToolMessage from the stream."""
329
+ msg = chunk_tuple[0] if isinstance(chunk_tuple, tuple) and len(chunk_tuple) >= 1 else chunk_tuple
330
+
331
+ if is_conversation_control_message(msg):
332
+ return
333
+
334
+ if isinstance(msg, AIMessageChunk):
335
+ # Extract thinking and text
336
+ text, thinking = _extract_text_and_thinking(
337
+ msg.content,
338
+ additional_kwargs=getattr(msg, "additional_kwargs", None),
339
+ response_metadata=getattr(msg, "response_metadata", None),
340
+ msg_obj=msg,
341
+ )
342
+ if thinking:
343
+ self._buffered_thinking.append(thinking)
344
+ await self.sink.on_thinking(thinking)
345
+ if text:
346
+ self._buffered_text.append(text)
347
+ await self.sink.on_token(text)
348
+
349
+ # Tool call chunks
350
+ if hasattr(msg, "tool_call_chunks") and msg.tool_call_chunks:
351
+ for tc in msg.tool_call_chunks:
352
+ call_id = tc.get("id") or ""
353
+ name = tc.get("name") or ""
354
+ raw_args = tc.get("args")
355
+ args: dict[str, Any] = raw_args if isinstance(raw_args, dict) else {}
356
+ if name:
357
+ await self.sink.on_tool_call_started(name, call_id, args)
358
+
359
+ elif isinstance(msg, ToolMessage):
360
+ call_id = getattr(msg, "tool_call_id", "") or ""
361
+ name = getattr(msg, "name", "") or "tool"
362
+ result = str(getattr(msg, "content", ""))
363
+ await self.sink.on_tool_call_completed(name, call_id, result)
364
+
365
+ async def _handle_updates(self, updates: Any) -> None:
366
+ """Handle state updates and __interrupt__ events."""
367
+ if not isinstance(updates, dict):
368
+ return
369
+
370
+ # Check for interrupts
371
+ interrupt_val = updates.get("__interrupt__")
372
+ if interrupt_val:
373
+ await self.sink.on_pre_interrupt()
374
+ if isinstance(interrupt_val, (list, tuple)) and interrupt_val:
375
+ raw_int = interrupt_val[0]
376
+ val = getattr(raw_int, "value", raw_int)
377
+ if isinstance(val, dict):
378
+ await self.sink.on_interrupt(val)
379
+ else:
380
+ await self.sink.on_interrupt({"raw": val})
381
+ elif isinstance(interrupt_val, dict):
382
+ await self.sink.on_interrupt(interrupt_val)
383
+
384
+ async def _handle_custom(self, custom: Any) -> None:
385
+ """Handle custom events (subagents, rubrics, auto-mode)."""
386
+ if not isinstance(custom, dict):
387
+ return
388
+
389
+ event_type = custom.get("type", "")
390
+ if event_type == "subagent":
391
+ name = custom.get("name", "subagent")
392
+ status = custom.get("status", "running")
393
+ await self.sink.on_subagent_event(name, status)
394
+ elif event_type.startswith("rubric_"):
395
+ await self.sink.on_rubric_event(custom)
396
+ elif event_type == "auto_mode":
397
+ await self.sink.on_auto_mode_event(custom)
@@ -0,0 +1,81 @@
1
+ """Bidirectional mapping between platform thread IDs and LangGraph thread IDs.
2
+
3
+ LangGraph requires a ``thread_id`` in the ``configurable`` dict for
4
+ checkpointing and interrupt / resume. Each messaging platform has its
5
+ own thread identifier format (Slack ``thread_ts``, Teams
6
+ ``conversationId``, etc.).
7
+
8
+ This store creates stable UUID-based LangGraph thread IDs and
9
+ maintains the mapping in both directions so that:
10
+
11
+ 1. An incoming platform event can be mapped to its LangGraph thread.
12
+ 2. An outgoing notification (e.g. from a cron-triggered run) can be
13
+ mapped back to the correct platform thread for delivery.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import uuid
19
+
20
+
21
+ class ThreadStore:
22
+ """Maps ``platform:thread_id`` ↔ ``langgraph_thread_id``.
23
+
24
+ The in-memory backend is suitable for development and single-process
25
+ deployments. For production multi-replica setups, swap to a
26
+ persistent backend (Postgres, Redis) by subclassing or passing
27
+ a ``backend`` parameter.
28
+ """
29
+
30
+ def __init__(self, backend: str = "memory") -> None:
31
+ """Initialize ThreadStore mapping platform and LangGraph threads.
32
+
33
+ Args:
34
+ backend: Storage backend identifier (default: "memory").
35
+ """
36
+ # In-memory dicts keyed by composite key "platform:thread_id"
37
+ self._platform_to_lg: dict[str, str] = {}
38
+ self._lg_to_platform: dict[str, dict[str, str]] = {}
39
+
40
+ # ── Forward lookup ────────────────────────────────────────────────
41
+
42
+ def resolve(self, platform: str, platform_thread_id: str) -> str:
43
+ """Get or create a LangGraph thread_id for a platform thread.
44
+
45
+ Thread IDs are stable — the same platform thread always maps to
46
+ the same LangGraph thread.
47
+
48
+ Args:
49
+ platform: Platform key (``"slack"``, ``"teams"``, etc.).
50
+ platform_thread_id: Platform-native thread identifier.
51
+
52
+ Returns:
53
+ A UUID string used as LangGraph's ``thread_id``.
54
+ """
55
+ key = f"{platform}:{platform_thread_id}"
56
+ if key not in self._platform_to_lg:
57
+ lg_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, key))
58
+ self._platform_to_lg[key] = lg_id
59
+ self._lg_to_platform[lg_id] = {
60
+ "platform": platform,
61
+ "thread_id": platform_thread_id,
62
+ }
63
+ return self._platform_to_lg[key]
64
+
65
+ # ── Reverse lookup ────────────────────────────────────────────────
66
+
67
+ def reverse_lookup(self, lg_thread_id: str) -> dict[str, str] | None:
68
+ """Look up platform info from a LangGraph thread_id.
69
+
70
+ Returns:
71
+ A dict ``{"platform": "slack", "thread_id": "..."}``
72
+ or ``None`` if the LangGraph thread_id is unknown.
73
+ """
74
+ return self._lg_to_platform.get(lg_thread_id)
75
+
76
+ # ── Existence check ───────────────────────────────────────────────
77
+
78
+ def has_thread(self, platform: str, platform_thread_id: str) -> bool:
79
+ """Check if a platform thread already has a LangGraph mapping."""
80
+ key = f"{platform}:{platform_thread_id}"
81
+ return key in self._platform_to_lg
opscloud/json_types.py ADDED
@@ -0,0 +1,20 @@
1
+ """Shared recursive types for JSON-compatible data in OpsCloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import JsonValue as PydanticJsonValue, TypeAdapter
6
+
7
+ type JsonScalar = str | int | float | bool | None
8
+ type JsonValue = PydanticJsonValue
9
+ type JsonObject = dict[str, JsonValue]
10
+
11
+ JSON_VALUE_ADAPTER: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)
12
+ JSON_OBJECT_ADAPTER: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
13
+
14
+ __all__ = [
15
+ "JSON_OBJECT_ADAPTER",
16
+ "JSON_VALUE_ADAPTER",
17
+ "JsonObject",
18
+ "JsonScalar",
19
+ "JsonValue",
20
+ ]
@@ -0,0 +1,57 @@
1
+ """Model Context Protocol (MCP) integration module for OpsCloud."""
2
+
3
+ from opscloud.mcp.config import resolve_mcp_server_env, sync_mcp_env_aliases
4
+ from opscloud.mcp.discovery import (
5
+ MCPDiscovery,
6
+ MCPServerConfig,
7
+ discover_mcp_configs,
8
+ )
9
+ from opscloud.mcp.mcp_info import (
10
+ MCPServerInfo,
11
+ MCPServerStatus,
12
+ MCPToolInfo,
13
+ )
14
+ from opscloud.mcp.preload import (
15
+ format_mcp_status_response,
16
+ preload_mcp_metadata,
17
+ preload_mcp_server_info,
18
+ probe_one_mcp_server,
19
+ )
20
+ from opscloud.mcp.raw_config import (
21
+ export_raw_mcp_config,
22
+ import_raw_mcp_config,
23
+ )
24
+ from opscloud.mcp.session_manager import (
25
+ MCPSessionManager,
26
+ _is_transient_session_error,
27
+ _normalize_mcp_arguments,
28
+ )
29
+ from opscloud.middleware.headless_mcp_guard import (
30
+ HeadlessMCPGuardMiddleware,
31
+ gated_mcp_tool_names,
32
+ mcp_tool_is_coherently_read_only,
33
+ )
34
+
35
+ __all__ = [
36
+ "HeadlessMCPGuardMiddleware",
37
+ "MCPDiscovery",
38
+ "MCPServerConfig",
39
+ "MCPServerInfo",
40
+ "MCPServerStatus",
41
+ "MCPSessionManager",
42
+ "MCPToolInfo",
43
+ "_is_transient_session_error",
44
+ "_normalize_mcp_arguments",
45
+ "discover_mcp_configs",
46
+ "export_raw_mcp_config",
47
+ "format_mcp_status_response",
48
+ "gated_mcp_tool_names",
49
+ "import_raw_mcp_config",
50
+ "mcp_tool_is_coherently_read_only",
51
+ "preload_mcp_metadata",
52
+ "preload_mcp_server_info",
53
+ "probe_one_mcp_server",
54
+ "resolve_mcp_server_env",
55
+ "sync_mcp_env_aliases",
56
+ ]
57
+