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.
- opscloud/__init__.py +5 -0
- opscloud/__main__.py +6 -0
- opscloud/_constants.py +53 -0
- opscloud/_debug.py +145 -0
- opscloud/_debug_buffer.py +250 -0
- opscloud/_textual_patches.py +367 -0
- opscloud/_version.py +15 -0
- opscloud/agent/__init__.py +27 -0
- opscloud/agent/config.py +168 -0
- opscloud/agent/factory.py +1103 -0
- opscloud/approval_mode.py +44 -0
- opscloud/backend/__init__.py +49 -0
- opscloud/backend/composite.py +89 -0
- opscloud/backend/local.py +183 -0
- opscloud/backend/registry.py +79 -0
- opscloud/backend/sandbox_config.py +107 -0
- opscloud/backend/sandbox_factory.py +432 -0
- opscloud/built_in_skills/remember/SKILL.md +124 -0
- opscloud/built_in_skills/skill-creator/SKILL.md +348 -0
- opscloud/built_in_skills/skill-creator/scripts/init_skill.py +371 -0
- opscloud/built_in_skills/skill-creator/scripts/quick_validate.py +165 -0
- opscloud/cli/__init__.py +1 -0
- opscloud/cli/commands/__init__.py +15 -0
- opscloud/cli/commands/auth.py +45 -0
- opscloud/cli/commands/doctor.py +100 -0
- opscloud/cli/commands/mcp.py +137 -0
- opscloud/cli/commands/threads.py +51 -0
- opscloud/cli/interactive.py +154 -0
- opscloud/cli/main.py +903 -0
- opscloud/cli/non_interactive.py +425 -0
- opscloud/cli/server_manager.py +58 -0
- opscloud/commands/__init__.py +19 -0
- opscloud/commands/_base.py +110 -0
- opscloud/commands/_guards.py +52 -0
- opscloud/commands/_router.py +146 -0
- opscloud/commands/_types.py +36 -0
- opscloud/commands/core/__init__.py +47 -0
- opscloud/commands/core/auth.py +94 -0
- opscloud/commands/core/bug.py +46 -0
- opscloud/commands/core/clear.py +106 -0
- opscloud/commands/core/cloud.py +185 -0
- opscloud/commands/core/compact.py +128 -0
- opscloud/commands/core/config_cmd.py +120 -0
- opscloud/commands/core/context.py +220 -0
- opscloud/commands/core/cost.py +44 -0
- opscloud/commands/core/doctor.py +535 -0
- opscloud/commands/core/effort.py +108 -0
- opscloud/commands/core/exit_cmd.py +41 -0
- opscloud/commands/core/fast.py +148 -0
- opscloud/commands/core/help_cmd.py +94 -0
- opscloud/commands/core/mcp.py +134 -0
- opscloud/commands/core/model.py +109 -0
- opscloud/commands/core/permissions.py +196 -0
- opscloud/commands/core/plugins.py +92 -0
- opscloud/commands/core/pool.py +108 -0
- opscloud/commands/core/resume.py +97 -0
- opscloud/commands/core/skills.py +178 -0
- opscloud/commands/power/__init__.py +51 -0
- opscloud/commands/power/agents.py +98 -0
- opscloud/commands/power/btw.py +85 -0
- opscloud/commands/power/copy.py +97 -0
- opscloud/commands/power/goal.py +548 -0
- opscloud/commands/power/loop.py +295 -0
- opscloud/commands/power/memory.py +287 -0
- opscloud/commands/power/review.py +152 -0
- opscloud/commands/power/rubric.py +287 -0
- opscloud/commands/power/runtime.py +321 -0
- opscloud/commands/power/skill_creator.py +70 -0
- opscloud/commands/power/skill_invoke.py +165 -0
- opscloud/commands/power/tasks.py +209 -0
- opscloud/commands/power/trace.py +117 -0
- opscloud/commands/power/ui_toggles.py +135 -0
- opscloud/commands/power/version.py +52 -0
- opscloud/commands/registry.py +176 -0
- opscloud/config/__init__.py +114 -0
- opscloud/config/adapters/__init__.py +5 -0
- opscloud/config/adapters/postgres.py +776 -0
- opscloud/config/adapters/sqlite.py +769 -0
- opscloud/config/aws.py +248 -0
- opscloud/config/cloud_profiles.py +437 -0
- opscloud/config/env_vars.py +81 -0
- opscloud/config/langsmith.py +319 -0
- opscloud/config/manifest.py +817 -0
- opscloud/config/metadata.py +205 -0
- opscloud/config/paths.py +604 -0
- opscloud/config/plugins.py +130 -0
- opscloud/config/settings.py +834 -0
- opscloud/config/store.py +453 -0
- opscloud/config/store_factory.py +97 -0
- opscloud/config/toml_config.py +562 -0
- opscloud/editor.py +173 -0
- opscloud/exceptions.py +65 -0
- opscloud/file_ops.py +217 -0
- opscloud/hooks/__init__.py +139 -0
- opscloud/hooks/env.py +44 -0
- opscloud/hooks/interrupt.py +121 -0
- opscloud/hooks/models/__init__.py +109 -0
- opscloud/hooks/models/adapters.py +19 -0
- opscloud/hooks/models/config.py +79 -0
- opscloud/hooks/models/domain.py +434 -0
- opscloud/hooks/models/transport.py +37 -0
- opscloud/hooks/tools.py +231 -0
- opscloud/input.py +48 -0
- opscloud/integrations/__init__.py +80 -0
- opscloud/integrations/base.py +153 -0
- opscloud/integrations/content_filter.py +119 -0
- opscloud/integrations/event_bus.py +480 -0
- opscloud/integrations/hooks.py +238 -0
- opscloud/integrations/identity.py +181 -0
- opscloud/integrations/notifications.py +134 -0
- opscloud/integrations/stream_bridge.py +397 -0
- opscloud/integrations/thread_store.py +81 -0
- opscloud/json_types.py +20 -0
- opscloud/mcp/__init__.py +57 -0
- opscloud/mcp/config.py +203 -0
- opscloud/mcp/discovery.py +229 -0
- opscloud/mcp/mcp_info.py +109 -0
- opscloud/mcp/middleware.py +102 -0
- opscloud/mcp/preload.py +505 -0
- opscloud/mcp/raw_config.py +149 -0
- opscloud/mcp/semantic_profiler.py +305 -0
- opscloud/mcp/session_manager.py +727 -0
- opscloud/mcp/trust.py +105 -0
- opscloud/memory/__init__.py +27 -0
- opscloud/memory/branch.py +61 -0
- opscloud/memory/guard.py +19 -0
- opscloud/memory/onboarding.py +120 -0
- opscloud/memory/registry.py +134 -0
- opscloud/memory/store.py +223 -0
- opscloud/middleware/__init__.py +106 -0
- opscloud/middleware/_repository_bounds.py +459 -0
- opscloud/middleware/ask_user.py +152 -0
- opscloud/middleware/auto_mode.py +60 -0
- opscloud/middleware/auto_mode_hitl.py +2434 -0
- opscloud/middleware/compaction.py +566 -0
- opscloud/middleware/configurable_model.py +233 -0
- opscloud/middleware/cost_tracking.py +1021 -0
- opscloud/middleware/glm_stall_recovery.py +109 -0
- opscloud/middleware/goal_criteria.py +1234 -0
- opscloud/middleware/goal_state_notice.py +712 -0
- opscloud/middleware/goal_tools.py +407 -0
- opscloud/middleware/headless_mcp_guard.py +88 -0
- opscloud/middleware/jev_model_router.py +1018 -0
- opscloud/middleware/local_context.py +460 -0
- opscloud/middleware/mcp_context.py +151 -0
- opscloud/middleware/mcp_middleware.py +146 -0
- opscloud/middleware/memory_guard.py +393 -0
- opscloud/middleware/model_retry.py +205 -0
- opscloud/middleware/registry.py +137 -0
- opscloud/middleware/reliable_rubric.py +709 -0
- opscloud/middleware/resume_state.py +91 -0
- opscloud/middleware/server_hooks.py +1189 -0
- opscloud/middleware/shell_allow_list.py +90 -0
- opscloud/middleware/skills.py +382 -0
- opscloud/middleware/subagent_artifacts.py +84 -0
- opscloud/middleware/subagent_telemetry.py +261 -0
- opscloud/middleware/subagents.py +243 -0
- opscloud/middleware/tool_filter.py +384 -0
- opscloud/middleware/unified_system_message.py +62 -0
- opscloud/model/__init__.py +37 -0
- opscloud/model/config.py +2992 -0
- opscloud/model/factory.py +477 -0
- opscloud/model/pool.py +459 -0
- opscloud/model/reasoning.py +282 -0
- opscloud/offload.py +173 -0
- opscloud/output.py +64 -0
- opscloud/plugins/__init__.py +189 -0
- opscloud/plugins/_json.py +22 -0
- opscloud/plugins/adapters/__init__.py +13 -0
- opscloud/plugins/adapters/agents.py +112 -0
- opscloud/plugins/adapters/commands.py +190 -0
- opscloud/plugins/adapters/mcp.py +363 -0
- opscloud/plugins/adapters/skills.py +132 -0
- opscloud/plugins/commands_cli.py +541 -0
- opscloud/plugins/discovery.py +512 -0
- opscloud/plugins/manifest.py +335 -0
- opscloud/plugins/marketplace.py +893 -0
- opscloud/plugins/models.py +334 -0
- opscloud/plugins/project_plugins.py +317 -0
- opscloud/plugins/protocol.py +31 -0
- opscloud/plugins/store.py +905 -0
- opscloud/plugins/substitution.py +122 -0
- opscloud/project_utils.py +145 -0
- opscloud/prompts/__init__.py +37 -0
- opscloud/prompts/system.py +483 -0
- opscloud/prompts/templates/system_prompt.md +166 -0
- opscloud/prompts/types.py +54 -0
- opscloud/rubrics/__init__.py +42 -0
- opscloud/rubrics/evaluator.py +404 -0
- opscloud/rubrics/evidence_extractor.py +229 -0
- opscloud/rubrics/generator.py +239 -0
- opscloud/rubrics/jev_compiler.py +175 -0
- opscloud/rubrics/jev_grader.py +297 -0
- opscloud/rubrics/middleware.py +8 -0
- opscloud/schema/__init__.py +107 -0
- opscloud/schema/interrupts.py +981 -0
- opscloud/security/__init__.py +91 -0
- opscloud/security/approval_mode.py +528 -0
- opscloud/security/approval_mode_source.py +230 -0
- opscloud/security/cli_ast_evaluator.py +552 -0
- opscloud/security/jev_classifier.py +651 -0
- opscloud/security/shell_safety.py +340 -0
- opscloud/security/unicode_security.py +439 -0
- opscloud/security/url_validation.py +160 -0
- opscloud/server/__init__.py +13 -0
- opscloud/server/_server_config.py +158 -0
- opscloud/server/server.py +242 -0
- opscloud/server/server_graph.py +93 -0
- opscloud/skills/__init__.py +91 -0
- opscloud/skills/commands.py +1099 -0
- opscloud/skills/invocation.py +200 -0
- opscloud/skills/load.py +388 -0
- opscloud/skills/loader.py +23 -0
- opscloud/skills/merge.py +65 -0
- opscloud/skills/registry.py +263 -0
- opscloud/skills/sources.py +174 -0
- opscloud/skills/trust.py +417 -0
- opscloud/state/__init__.py +142 -0
- opscloud/state/base.py +154 -0
- opscloud/state/cloud_context.py +262 -0
- opscloud/state/goal_channels.py +124 -0
- opscloud/state/goal_state_limits.py +175 -0
- opscloud/state/resume_state.py +58 -0
- opscloud/state/service_context.py +281 -0
- opscloud/state/session.py +957 -0
- opscloud/state/state_migration.py +86 -0
- opscloud/subagents/__init__.py +30 -0
- opscloud/subagents/loader.py +331 -0
- opscloud/subagents/subagents_parser.py +117 -0
- opscloud/subagents/types.py +25 -0
- opscloud/tools/__init__.py +36 -0
- opscloud/tools/catalog.py +37 -0
- opscloud/tools/display.py +24 -0
- opscloud/tools/fetch_url.py +176 -0
- opscloud/tools/goal_tools.py +423 -0
- opscloud/tools/registry.py +105 -0
- opscloud/tools/thread.py +32 -0
- opscloud/tools/web_search.py +215 -0
- opscloud/ui/__init__.py +119 -0
- opscloud/ui/app.py +5400 -0
- opscloud/ui/app.tcss +479 -0
- opscloud/ui/clipboard.py +147 -0
- opscloud/ui/command_registry.py +482 -0
- opscloud/ui/message_store.py +152 -0
- opscloud/ui/permission_store.py +360 -0
- opscloud/ui/preferences.py +113 -0
- opscloud/ui/prompt_manager.py +96 -0
- opscloud/ui/remote_client.py +505 -0
- opscloud/ui/textual_adapter.py +1269 -0
- opscloud/ui/theme.py +728 -0
- opscloud/ui/ui_help.py +415 -0
- opscloud/ui/widgets/__init__.py +383 -0
- opscloud/ui/widgets/_ask_user_types.py +81 -0
- opscloud/ui/widgets/_copy_spans.py +52 -0
- opscloud/ui/widgets/_inline_prompt.py +290 -0
- opscloud/ui/widgets/_js_eval_display.py +139 -0
- opscloud/ui/widgets/_links.py +175 -0
- opscloud/ui/widgets/_tool_stream.py +308 -0
- opscloud/ui/widgets/agent_selector.py +297 -0
- opscloud/ui/widgets/approval.py +741 -0
- opscloud/ui/widgets/ask_user.py +539 -0
- opscloud/ui/widgets/auth.py +695 -0
- opscloud/ui/widgets/auth_manager.py +214 -0
- opscloud/ui/widgets/auto_mode_notice.py +146 -0
- opscloud/ui/widgets/autocomplete.py +709 -0
- opscloud/ui/widgets/chat_input.py +805 -0
- opscloud/ui/widgets/cloud_selector.py +528 -0
- opscloud/ui/widgets/config_manager.py +652 -0
- opscloud/ui/widgets/debug_console.py +1269 -0
- opscloud/ui/widgets/devops_renderers.py +184 -0
- opscloud/ui/widgets/diff.py +237 -0
- opscloud/ui/widgets/effort_selector.py +166 -0
- opscloud/ui/widgets/goal_review.py +612 -0
- opscloud/ui/widgets/goal_status.py +57 -0
- opscloud/ui/widgets/infra_panel.py +141 -0
- opscloud/ui/widgets/install_confirm.py +114 -0
- opscloud/ui/widgets/loading.py +170 -0
- opscloud/ui/widgets/mcp_viewer.py +1204 -0
- opscloud/ui/widgets/messages.py +4461 -0
- opscloud/ui/widgets/model_selector.py +863 -0
- opscloud/ui/widgets/notification_center.py +108 -0
- opscloud/ui/widgets/notification_settings.py +119 -0
- opscloud/ui/widgets/operation_card.py +106 -0
- opscloud/ui/widgets/permissions_manager.py +667 -0
- opscloud/ui/widgets/plugin_manager.py +1212 -0
- opscloud/ui/widgets/pool_selector.py +376 -0
- opscloud/ui/widgets/preamble.py +53 -0
- opscloud/ui/widgets/skills_viewer.py +432 -0
- opscloud/ui/widgets/status.py +1037 -0
- opscloud/ui/widgets/subagent_panel.py +927 -0
- opscloud/ui/widgets/theme_selector.py +232 -0
- opscloud/ui/widgets/thread_selector.py +358 -0
- opscloud/ui/widgets/toast.py +31 -0
- opscloud/ui/widgets/tool_display.py +325 -0
- opscloud/ui/widgets/tool_renderers.py +234 -0
- opscloud/ui/widgets/tool_widgets.py +278 -0
- opscloud/ui/widgets/welcome.py +421 -0
- opscloud/ui/widgets/welcome_popup.py +102 -0
- opscloud/utils/__init__.py +24 -0
- opscloud/utils/cost_estimation.py +169 -0
- opscloud/utils/git.py +390 -0
- opscloud/utils/logger.py +571 -0
- opscloud/utils/session_stats.py +115 -0
- opscloud/utils/startup_error.py +37 -0
- talkops_opscloud-0.3.0b1.dist-info/METADATA +449 -0
- talkops_opscloud-0.3.0b1.dist-info/RECORD +309 -0
- talkops_opscloud-0.3.0b1.dist-info/WHEEL +4 -0
- talkops_opscloud-0.3.0b1.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
opscloud/mcp/__init__.py
ADDED
|
@@ -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
|
+
|