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,90 @@
|
|
|
1
|
+
"""Middleware for enforcing shell command allow-lists."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from langchain.agents.middleware.types import AgentMiddleware
|
|
9
|
+
from langchain_core.messages import ToolMessage
|
|
10
|
+
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
11
|
+
|
|
12
|
+
from opscloud.middleware.registry import register_middleware
|
|
13
|
+
from opscloud.security.shell_safety import is_shell_command_allowed
|
|
14
|
+
from opscloud.utils.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@register_middleware(name="shell_allow_list")
|
|
20
|
+
class ShellAllowListMiddleware(AgentMiddleware[Any, Any]):
|
|
21
|
+
"""Validate shell commands against an allow-list without HITL interrupts."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, allow_list: list[str] | None = None) -> None:
|
|
24
|
+
"""Initialize ShellAllowListMiddleware.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
allow_list: Optional list of allowed shell executable binary names or command prefixes.
|
|
28
|
+
"""
|
|
29
|
+
super().__init__()
|
|
30
|
+
self._allow_list = allow_list or []
|
|
31
|
+
|
|
32
|
+
def _validate_tool_call(self, request: ToolCallRequest) -> ToolMessage | None:
|
|
33
|
+
if not getattr(request, "tool_call", None) or not isinstance(request.tool_call, dict):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
tool_name = request.tool_call.get("name")
|
|
37
|
+
if tool_name not in {"execute", "run_command"}:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
args = request.tool_call.get("args") or {}
|
|
41
|
+
command = args.get("command") or args.get("CommandLine") or ""
|
|
42
|
+
if not self._allow_list:
|
|
43
|
+
return ToolMessage(
|
|
44
|
+
content="Shell command rejected: no commands are allowed in this mode.",
|
|
45
|
+
name=str(tool_name),
|
|
46
|
+
tool_call_id=str(request.tool_call.get("id") or ""),
|
|
47
|
+
status="error",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if is_shell_command_allowed(command, self._allow_list):
|
|
51
|
+
logger.debug("Shell command allowed: %r", command)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
logger.warning("Shell command rejected by allow-list: %r", command)
|
|
55
|
+
allowed_str = ", ".join(self._allow_list)
|
|
56
|
+
return ToolMessage(
|
|
57
|
+
content=(
|
|
58
|
+
f"Shell command rejected: `{command}` is not in the allow-list. "
|
|
59
|
+
f"Allowed commands: {allowed_str}. "
|
|
60
|
+
f"Please use an allowed command or try another approach."
|
|
61
|
+
),
|
|
62
|
+
name=str(tool_name),
|
|
63
|
+
tool_call_id=str(request.tool_call.get("id") or ""),
|
|
64
|
+
status="error",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def wrap_tool_call(
|
|
68
|
+
self,
|
|
69
|
+
request: ToolCallRequest,
|
|
70
|
+
handler: Callable[[ToolCallRequest], Any],
|
|
71
|
+
) -> Any:
|
|
72
|
+
"""Wrap synchronous tool call and block disallowed shell commands."""
|
|
73
|
+
err = self._validate_tool_call(request)
|
|
74
|
+
if err is not None:
|
|
75
|
+
return err
|
|
76
|
+
return handler(request)
|
|
77
|
+
|
|
78
|
+
async def awrap_tool_call(
|
|
79
|
+
self,
|
|
80
|
+
request: ToolCallRequest,
|
|
81
|
+
handler: Callable[[ToolCallRequest], Any],
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Wrap asynchronous tool call and block disallowed shell commands."""
|
|
84
|
+
err = self._validate_tool_call(request)
|
|
85
|
+
if err is not None:
|
|
86
|
+
return err
|
|
87
|
+
return await handler(request)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["ShellAllowListMiddleware"]
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Skills middleware adapter supporting namespaced plugin skills."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
import fnmatch
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
10
|
+
|
|
11
|
+
from deepagents.backends.protocol import FileInfo, LsResult
|
|
12
|
+
from deepagents.backends.utils import to_posix_path
|
|
13
|
+
from deepagents.middleware import skills as sdk_skills
|
|
14
|
+
from deepagents.middleware.skills import SkillsMiddleware
|
|
15
|
+
|
|
16
|
+
from opscloud.middleware.registry import register_middleware
|
|
17
|
+
from opscloud.plugins.adapters.skills import (
|
|
18
|
+
SkillNamespace,
|
|
19
|
+
namespaced_skill_name,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from deepagents.backends.protocol import BackendProtocol
|
|
24
|
+
from langchain_core.runnables import RunnableConfig
|
|
25
|
+
from langgraph.runtime import Runtime
|
|
26
|
+
from opscloud.skills.registry import SkillSourceTuple
|
|
27
|
+
from opscloud.skills.sources import CodeSkillSource
|
|
28
|
+
|
|
29
|
+
from opscloud.utils.logger import get_logger
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
_PLUGIN_SKILL_SOURCE_LENGTH = 3
|
|
34
|
+
_SKILL_FILE = "SKILL.md"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _entries(ls_result: object) -> list[FileInfo]:
|
|
38
|
+
if isinstance(ls_result, LsResult):
|
|
39
|
+
return list(ls_result.entries or [])
|
|
40
|
+
if isinstance(ls_result, list):
|
|
41
|
+
return cast(list[FileInfo], ls_result)
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _child_dirs(entries: list[FileInfo], root: str) -> list[tuple[str, str]]:
|
|
46
|
+
root_posix = PurePosixPath(to_posix_path(root))
|
|
47
|
+
dirs: list[tuple[str, str]] = []
|
|
48
|
+
for entry in entries:
|
|
49
|
+
if not entry.get("is_dir"):
|
|
50
|
+
continue
|
|
51
|
+
path = entry["path"]
|
|
52
|
+
name = PurePosixPath(to_posix_path(path)).name
|
|
53
|
+
if PurePosixPath(to_posix_path(path)) == root_posix:
|
|
54
|
+
continue
|
|
55
|
+
dirs.append((name, path))
|
|
56
|
+
return dirs
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _has_skill_file(entries: list[FileInfo], root: str) -> bool:
|
|
60
|
+
root_posix = PurePosixPath(to_posix_path(root))
|
|
61
|
+
for entry in entries:
|
|
62
|
+
path = PurePosixPath(to_posix_path(entry["path"]))
|
|
63
|
+
if path.name == _SKILL_FILE and path.parent == root_posix:
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _skill_md_path(skill_dir: str) -> str:
|
|
69
|
+
return str(PurePosixPath(to_posix_path(skill_dir)) / _SKILL_FILE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _namespace_skill(
|
|
73
|
+
skill: sdk_skills.SkillMetadata,
|
|
74
|
+
namespace: SkillNamespace,
|
|
75
|
+
subfolders: tuple[str, ...],
|
|
76
|
+
) -> sdk_skills.SkillMetadata:
|
|
77
|
+
return cast(
|
|
78
|
+
sdk_skills.SkillMetadata,
|
|
79
|
+
{
|
|
80
|
+
**skill,
|
|
81
|
+
"name": namespaced_skill_name(namespace, skill["name"], subfolders),
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def discover_skill_dirs(
|
|
87
|
+
backend: BackendProtocol,
|
|
88
|
+
source_path: str,
|
|
89
|
+
) -> list[tuple[str, tuple[str, ...]]]:
|
|
90
|
+
"""Recursively search for directories containing a SKILL.md definition under source_path.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
backend: Storage backend providing directory listing capabilities.
|
|
94
|
+
source_path: Root filesystem path to search.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of tuples mapping resolved skill directory path to relative path segments.
|
|
98
|
+
"""
|
|
99
|
+
found: list[tuple[str, tuple[str, ...]]] = []
|
|
100
|
+
source_root = Path(source_path).resolve()
|
|
101
|
+
visited: set[Path] = set()
|
|
102
|
+
stack: list[tuple[str, tuple[str, ...]]] = [(str(source_root), ())]
|
|
103
|
+
while stack:
|
|
104
|
+
current, path_segments = stack.pop()
|
|
105
|
+
try:
|
|
106
|
+
resolved = Path(current).resolve()
|
|
107
|
+
except (OSError, RuntimeError):
|
|
108
|
+
logger.warning("Could not resolve plugin skill directory %s", current)
|
|
109
|
+
continue
|
|
110
|
+
if not resolved.is_relative_to(source_root) or resolved in visited:
|
|
111
|
+
continue
|
|
112
|
+
visited.add(resolved)
|
|
113
|
+
resolved_path = str(resolved)
|
|
114
|
+
entries = _entries(backend.ls(resolved_path))
|
|
115
|
+
if _has_skill_file(entries, resolved_path):
|
|
116
|
+
found.append((resolved_path, path_segments[:-1]))
|
|
117
|
+
continue
|
|
118
|
+
for name, path in _child_dirs(entries, resolved_path):
|
|
119
|
+
stack.append((path, (*path_segments, name)))
|
|
120
|
+
return found
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_namespaced_skills(
|
|
124
|
+
backend: BackendProtocol,
|
|
125
|
+
source_path: str,
|
|
126
|
+
namespace: SkillNamespace,
|
|
127
|
+
) -> list[sdk_skills.SkillMetadata]:
|
|
128
|
+
"""Discover and parse all skill definitions within a source root under a given namespace.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
backend: Storage backend providing file reading and listing.
|
|
132
|
+
source_path: Root directory path containing skills.
|
|
133
|
+
namespace: Skill namespace identifier for prefixing skill names.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of loaded and namespaced SkillMetadata objects.
|
|
137
|
+
"""
|
|
138
|
+
skill_dirs = discover_skill_dirs(backend, source_path)
|
|
139
|
+
if not skill_dirs:
|
|
140
|
+
return []
|
|
141
|
+
paths = [_skill_md_path(skill_dir) for skill_dir, _ in skill_dirs]
|
|
142
|
+
responses = backend.download_files(paths)
|
|
143
|
+
skills: list[sdk_skills.SkillMetadata] = []
|
|
144
|
+
for (skill_dir, segments), path, response in zip(skill_dirs, paths, responses, strict=True):
|
|
145
|
+
skill = sdk_skills._skill_metadata_from_response(response, skill_dir, path)
|
|
146
|
+
if skill is not None:
|
|
147
|
+
skills.append(_namespace_skill(skill, namespace, segments))
|
|
148
|
+
return skills
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
CRITERIA_SKILLS_SYSTEM_PROMPT = """## Skills System & Domain Capabilities
|
|
152
|
+
|
|
153
|
+
OpsCloud operates with a skills library providing domain knowledge and specialized cloud operations procedures. Use these capabilities to understand what criteria and verification targets are achievable:
|
|
154
|
+
{skills_locations}{skills_load_warnings}
|
|
155
|
+
**Available Skills:**
|
|
156
|
+
|
|
157
|
+
{skills_list}"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@register_middleware(name="skills")
|
|
161
|
+
class PluginSkillsMiddleware(SkillsMiddleware):
|
|
162
|
+
"""Load namespaced plugin skills with optional whitelist filtering for subagent scoping."""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
backend: BackendProtocol | None = None,
|
|
168
|
+
sources: Sequence[CodeSkillSource | SkillSourceTuple | tuple[str, ...]] | None = None,
|
|
169
|
+
skill_sources: Sequence[Any] | None = None,
|
|
170
|
+
system_prompt: str | None = None,
|
|
171
|
+
allowed_skills: Sequence[str] | None = None,
|
|
172
|
+
include_subagent_skills: bool = False,
|
|
173
|
+
subagents: Sequence[Any] | None = None,
|
|
174
|
+
planning_mode: bool = False,
|
|
175
|
+
project_root: Path | str | None = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Initialize PluginSkillsMiddleware with backend, sources, and filtering options.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
backend: Storage backend instance.
|
|
181
|
+
sources: Custom skill root sources.
|
|
182
|
+
skill_sources: Structured skill source definitions.
|
|
183
|
+
system_prompt: Custom system prompt template for skill injection.
|
|
184
|
+
allowed_skills: Optional whitelist of skill names permitted for execution.
|
|
185
|
+
include_subagent_skills: Whether to inherit subagent-specific skills.
|
|
186
|
+
subagents: Sequence of subagents whose skills to discover.
|
|
187
|
+
planning_mode: Whether planning mode is active.
|
|
188
|
+
project_root: Optional project root path for skill discovery.
|
|
189
|
+
"""
|
|
190
|
+
self._planning_mode = planning_mode
|
|
191
|
+
self._project_root = Path(project_root) if project_root else None
|
|
192
|
+
if system_prompt is None:
|
|
193
|
+
system_prompt = CRITERIA_SKILLS_SYSTEM_PROMPT if planning_mode else sdk_skills.SKILLS_SYSTEM_PROMPT
|
|
194
|
+
|
|
195
|
+
if backend is None:
|
|
196
|
+
from deepagents.backends.filesystem import FilesystemBackend
|
|
197
|
+
|
|
198
|
+
backend = FilesystemBackend(virtual_mode=False)
|
|
199
|
+
|
|
200
|
+
self._dynamic_sources = sources is None and skill_sources is None
|
|
201
|
+
self._include_subagent_skills = include_subagent_skills
|
|
202
|
+
self._subagents = subagents
|
|
203
|
+
if sources is None:
|
|
204
|
+
if skill_sources is not None:
|
|
205
|
+
sources = [(str(getattr(s, "path", s)), getattr(s, "name", str(s))) for s in skill_sources]
|
|
206
|
+
else:
|
|
207
|
+
from opscloud.skills.sources import get_skill_sources
|
|
208
|
+
|
|
209
|
+
sources = get_skill_sources(
|
|
210
|
+
project_root=self._project_root,
|
|
211
|
+
include_subagent_skills=include_subagent_skills,
|
|
212
|
+
subagents=subagents,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
sdk_sources = [(source[0], source[1]) for source in sources]
|
|
216
|
+
super().__init__(
|
|
217
|
+
backend=backend,
|
|
218
|
+
sources=sdk_sources,
|
|
219
|
+
system_prompt=system_prompt,
|
|
220
|
+
)
|
|
221
|
+
self._namespaces = tuple(
|
|
222
|
+
source[2] if len(source) == _PLUGIN_SKILL_SOURCE_LENGTH else None for source in sources
|
|
223
|
+
)
|
|
224
|
+
self._allowed_skills = tuple(allowed_skills) if allowed_skills is not None else None
|
|
225
|
+
|
|
226
|
+
def _format_skills_locations(self) -> str:
|
|
227
|
+
"""Format skills locations for display in system prompt."""
|
|
228
|
+
if self._planning_mode:
|
|
229
|
+
return ""
|
|
230
|
+
return super()._format_skills_locations()
|
|
231
|
+
|
|
232
|
+
def _format_skills_load_warnings(self, errors: list[str]) -> str:
|
|
233
|
+
"""Format skills load warnings for display in system prompt."""
|
|
234
|
+
if self._planning_mode:
|
|
235
|
+
return ""
|
|
236
|
+
return super()._format_skills_load_warnings(errors)
|
|
237
|
+
|
|
238
|
+
def _format_skills_list(self, skills: list[sdk_skills.SkillMetadata]) -> str:
|
|
239
|
+
"""Format skills metadata cleanly when in planning mode without filesystem paths."""
|
|
240
|
+
if self._planning_mode:
|
|
241
|
+
if not skills:
|
|
242
|
+
return "(No specialized skills currently loaded.)"
|
|
243
|
+
lines: list[str] = []
|
|
244
|
+
for skill in skills:
|
|
245
|
+
name = skill.get("name", "")
|
|
246
|
+
desc = skill.get("description", "").strip()
|
|
247
|
+
lines.append(f"- **{name}**: {desc}")
|
|
248
|
+
return "\n".join(lines)
|
|
249
|
+
return super()._format_skills_list(skills)
|
|
250
|
+
|
|
251
|
+
def _is_skill_allowed(self, skill_name: str) -> bool:
|
|
252
|
+
if self._allowed_skills is None:
|
|
253
|
+
return True
|
|
254
|
+
for pattern in self._allowed_skills:
|
|
255
|
+
if fnmatch.fnmatch(skill_name, pattern) or fnmatch.fnmatch(skill_name.lower(), pattern.lower()):
|
|
256
|
+
return True
|
|
257
|
+
if ":" in pattern:
|
|
258
|
+
prefix, base_pat = pattern.split(":", 1)
|
|
259
|
+
if base_pat == "*" or fnmatch.fnmatch(skill_name, base_pat) or fnmatch.fnmatch(skill_name.lower(), base_pat.lower()):
|
|
260
|
+
return True
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
def _get_live_skills(self) -> tuple[list[sdk_skills.SkillMetadata], list[str]]:
|
|
264
|
+
if self._dynamic_sources:
|
|
265
|
+
from opscloud.skills.sources import get_skill_sources
|
|
266
|
+
|
|
267
|
+
live_sources = get_skill_sources(
|
|
268
|
+
project_root=self._project_root,
|
|
269
|
+
include_subagent_skills=self._include_subagent_skills,
|
|
270
|
+
subagents=self._subagents,
|
|
271
|
+
)
|
|
272
|
+
self.sources = [s[0] for s in live_sources]
|
|
273
|
+
self.source_labels = [s[1] for s in live_sources]
|
|
274
|
+
self._namespaces = tuple(s[2] if len(s) == _PLUGIN_SKILL_SOURCE_LENGTH else None for s in live_sources)
|
|
275
|
+
|
|
276
|
+
backend = self._backend
|
|
277
|
+
all_skills: dict[str, sdk_skills.SkillMetadata] = {}
|
|
278
|
+
errors: list[str] = []
|
|
279
|
+
|
|
280
|
+
active_sources: list[tuple[str, str, str | None]] = []
|
|
281
|
+
for source_path, source_label, namespace in zip(
|
|
282
|
+
self.sources, self.source_labels, self._namespaces, strict=True
|
|
283
|
+
):
|
|
284
|
+
if namespace is None:
|
|
285
|
+
source_skills, source_error = sdk_skills._list_skills_with_errors(backend, source_path)
|
|
286
|
+
if source_error is not None:
|
|
287
|
+
errors.append(source_error)
|
|
288
|
+
else:
|
|
289
|
+
source_skills = load_namespaced_skills(backend, source_path, namespace)
|
|
290
|
+
|
|
291
|
+
source_has_skills = False
|
|
292
|
+
for skill in source_skills:
|
|
293
|
+
skill_name = skill["name"]
|
|
294
|
+
if self._is_skill_allowed(skill_name):
|
|
295
|
+
all_skills[skill_name] = skill
|
|
296
|
+
source_has_skills = True
|
|
297
|
+
|
|
298
|
+
if source_has_skills:
|
|
299
|
+
active_sources.append((source_path, source_label, namespace))
|
|
300
|
+
|
|
301
|
+
if active_sources:
|
|
302
|
+
self.sources = [s[0] for s in active_sources]
|
|
303
|
+
self.source_labels = [s[1] for s in active_sources]
|
|
304
|
+
self._namespaces = tuple(s[2] for s in active_sources)
|
|
305
|
+
|
|
306
|
+
return list(all_skills.values()), errors
|
|
307
|
+
|
|
308
|
+
def before_agent(
|
|
309
|
+
self,
|
|
310
|
+
state: sdk_skills.SkillsState,
|
|
311
|
+
runtime: Runtime,
|
|
312
|
+
config: RunnableConfig,
|
|
313
|
+
) -> sdk_skills.SkillsStateUpdate | None:
|
|
314
|
+
"""Discover live skills and prepare state updates before agent execution.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
state: Current agent state.
|
|
318
|
+
runtime: Active execution runtime.
|
|
319
|
+
config: Runnable configuration for the invocation.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
SkillsStateUpdate if changes were detected, or None.
|
|
323
|
+
"""
|
|
324
|
+
live_skills, errors = self._get_live_skills()
|
|
325
|
+
current_skills = state.get("skills_metadata") or []
|
|
326
|
+
current_names = [s["name"] for s in current_skills]
|
|
327
|
+
live_names = [s["name"] for s in live_skills]
|
|
328
|
+
|
|
329
|
+
if current_names == live_names and state.get("skills_metadata") is not None:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
update = sdk_skills.SkillsStateUpdate(skills_metadata=live_skills)
|
|
333
|
+
if errors:
|
|
334
|
+
logger.warning("Skills load errors: %s", errors)
|
|
335
|
+
update["skills_load_errors"] = errors
|
|
336
|
+
return update
|
|
337
|
+
|
|
338
|
+
async def abefore_agent(
|
|
339
|
+
self,
|
|
340
|
+
state: sdk_skills.SkillsState,
|
|
341
|
+
runtime: Runtime,
|
|
342
|
+
config: RunnableConfig,
|
|
343
|
+
) -> sdk_skills.SkillsStateUpdate | None:
|
|
344
|
+
"""Asynchronously discover live skills and prepare state updates before agent execution.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
state: Current agent state.
|
|
348
|
+
runtime: Active execution runtime.
|
|
349
|
+
config: Runnable configuration for the invocation.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
SkillsStateUpdate if changes were detected, or None.
|
|
353
|
+
"""
|
|
354
|
+
return await asyncio.to_thread(self.before_agent, state, runtime, config)
|
|
355
|
+
|
|
356
|
+
def modify_request(self, request: Any) -> Any:
|
|
357
|
+
"""Filter out stale skill metadata before formatting the system prompt.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
request: Model request to modify.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Modified request with pruned skill metadata.
|
|
364
|
+
"""
|
|
365
|
+
live_skills, _ = self._get_live_skills()
|
|
366
|
+
live_names = {s["name"] for s in live_skills}
|
|
367
|
+
|
|
368
|
+
if hasattr(request, "state") and isinstance(request.state, dict):
|
|
369
|
+
# Prune any stale or uninstalled skills from request state before formatting system prompt
|
|
370
|
+
existing = request.state.get("skills_metadata") or []
|
|
371
|
+
request.state["skills_metadata"] = [s for s in existing if s.get("name") in live_names]
|
|
372
|
+
if not request.state["skills_metadata"]:
|
|
373
|
+
request.state["skills_metadata"] = live_skills
|
|
374
|
+
|
|
375
|
+
return super().modify_request(request)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = [
|
|
379
|
+
"PluginSkillsMiddleware",
|
|
380
|
+
"discover_skill_dirs",
|
|
381
|
+
"load_namespaced_skills",
|
|
382
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Middleware to inject artifact and reporting guidelines for subagents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from langchain.agents.middleware.types import (
|
|
9
|
+
AgentMiddleware,
|
|
10
|
+
ExtendedModelResponse,
|
|
11
|
+
ModelRequest,
|
|
12
|
+
ModelResponse,
|
|
13
|
+
)
|
|
14
|
+
from langchain_core.messages import SystemMessage
|
|
15
|
+
|
|
16
|
+
from opscloud.middleware.registry import register_middleware
|
|
17
|
+
from opscloud.utils.logger import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
ARTIFACT_GUIDELINES = """
|
|
22
|
+
## Artifacts & Reporting Mechanics
|
|
23
|
+
|
|
24
|
+
You are equipped with the ability to generate "Artifacts". An Artifact is a persistent, well-formatted Markdown document used to present structured information (e.g., an AWS security audit, an architecture review, or tabular pricing data).
|
|
25
|
+
|
|
26
|
+
CRITICAL RULE:
|
|
27
|
+
- Do NOT create excessive markdown summary files after completing work.
|
|
28
|
+
- Focus on the work itself, not documenting what you did.
|
|
29
|
+
- Only create documentation or reports when EXPLICITLY requested by the user. For all other work, use a standard text response.
|
|
30
|
+
|
|
31
|
+
When generating a requested report:
|
|
32
|
+
1. Use your file writing tools to save the report to the local file system.
|
|
33
|
+
2. Always save reports in the workspace with a descriptive filename (e.g., `s3_audit_report.md`).
|
|
34
|
+
3. Format the content using standard GitHub Flavored Markdown (Mermaid diagrams, tables).
|
|
35
|
+
4. DO NOT re-summarize the entire report in your text response. Simply provide the file path and highlight the top 2 critical findings.
|
|
36
|
+
|
|
37
|
+
## Context Management & Summarization
|
|
38
|
+
- You are equipped with automatic context compaction and the `compact_conversation` tool.
|
|
39
|
+
- If your delegated task requires extensive exploration, executing many CLI/API commands, or reading large files, older turns are automatically summarized into concise milestones and offloaded to `/conversation_history/` on the workspace backend to preserve your context window.
|
|
40
|
+
- When delivering your final result back to the orchestrator, provide a crisp, direct summary of your actions, discovered findings, and modified resources so the parent agent has immediate clarity.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
@register_middleware(name="subagent_artifacts")
|
|
44
|
+
class SubagentArtifactsMiddleware(AgentMiddleware[Any, Any]):
|
|
45
|
+
"""Middleware that injects Artifact reporting rules into the subagent's system prompt."""
|
|
46
|
+
|
|
47
|
+
def wrap_model_call(
|
|
48
|
+
self,
|
|
49
|
+
request: ModelRequest,
|
|
50
|
+
handler: Callable[[ModelRequest], ModelResponse],
|
|
51
|
+
) -> ModelResponse | ExtendedModelResponse:
|
|
52
|
+
|
|
53
|
+
# Inject the guidelines into the existing system message
|
|
54
|
+
if request.system_message is not None:
|
|
55
|
+
curr_content = request.system_message.content or ""
|
|
56
|
+
if isinstance(curr_content, str):
|
|
57
|
+
new_content = curr_content + "\n" + ARTIFACT_GUIDELINES
|
|
58
|
+
elif isinstance(curr_content, list):
|
|
59
|
+
new_content = list(curr_content) + [ARTIFACT_GUIDELINES]
|
|
60
|
+
else:
|
|
61
|
+
new_content = ARTIFACT_GUIDELINES
|
|
62
|
+
|
|
63
|
+
request = request.override(system_message=SystemMessage(content=new_content))
|
|
64
|
+
|
|
65
|
+
return handler(request)
|
|
66
|
+
|
|
67
|
+
async def awrap_model_call(
|
|
68
|
+
self,
|
|
69
|
+
request: ModelRequest,
|
|
70
|
+
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
|
71
|
+
) -> ModelResponse | ExtendedModelResponse:
|
|
72
|
+
|
|
73
|
+
if request.system_message is not None:
|
|
74
|
+
curr_content = request.system_message.content or ""
|
|
75
|
+
if isinstance(curr_content, str):
|
|
76
|
+
new_content = curr_content + "\n" + ARTIFACT_GUIDELINES
|
|
77
|
+
elif isinstance(curr_content, list):
|
|
78
|
+
new_content = list(curr_content) + [ARTIFACT_GUIDELINES]
|
|
79
|
+
else:
|
|
80
|
+
new_content = ARTIFACT_GUIDELINES
|
|
81
|
+
|
|
82
|
+
request = request.override(system_message=SystemMessage(content=new_content))
|
|
83
|
+
|
|
84
|
+
return await handler(request)
|