aury-agent 0.0.4__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 (149) hide show
  1. aury/__init__.py +2 -0
  2. aury/agents/__init__.py +55 -0
  3. aury/agents/a2a/__init__.py +168 -0
  4. aury/agents/backends/__init__.py +196 -0
  5. aury/agents/backends/artifact/__init__.py +9 -0
  6. aury/agents/backends/artifact/memory.py +130 -0
  7. aury/agents/backends/artifact/types.py +133 -0
  8. aury/agents/backends/code/__init__.py +65 -0
  9. aury/agents/backends/file/__init__.py +11 -0
  10. aury/agents/backends/file/local.py +66 -0
  11. aury/agents/backends/file/types.py +40 -0
  12. aury/agents/backends/invocation/__init__.py +8 -0
  13. aury/agents/backends/invocation/memory.py +81 -0
  14. aury/agents/backends/invocation/types.py +110 -0
  15. aury/agents/backends/memory/__init__.py +8 -0
  16. aury/agents/backends/memory/memory.py +179 -0
  17. aury/agents/backends/memory/types.py +136 -0
  18. aury/agents/backends/message/__init__.py +9 -0
  19. aury/agents/backends/message/memory.py +122 -0
  20. aury/agents/backends/message/types.py +124 -0
  21. aury/agents/backends/sandbox.py +275 -0
  22. aury/agents/backends/session/__init__.py +8 -0
  23. aury/agents/backends/session/memory.py +93 -0
  24. aury/agents/backends/session/types.py +124 -0
  25. aury/agents/backends/shell/__init__.py +11 -0
  26. aury/agents/backends/shell/local.py +110 -0
  27. aury/agents/backends/shell/types.py +55 -0
  28. aury/agents/backends/shell.py +209 -0
  29. aury/agents/backends/snapshot/__init__.py +19 -0
  30. aury/agents/backends/snapshot/git.py +95 -0
  31. aury/agents/backends/snapshot/hybrid.py +125 -0
  32. aury/agents/backends/snapshot/memory.py +86 -0
  33. aury/agents/backends/snapshot/types.py +59 -0
  34. aury/agents/backends/state/__init__.py +29 -0
  35. aury/agents/backends/state/composite.py +49 -0
  36. aury/agents/backends/state/file.py +57 -0
  37. aury/agents/backends/state/memory.py +52 -0
  38. aury/agents/backends/state/sqlite.py +262 -0
  39. aury/agents/backends/state/types.py +178 -0
  40. aury/agents/backends/subagent/__init__.py +165 -0
  41. aury/agents/cli/__init__.py +41 -0
  42. aury/agents/cli/chat.py +239 -0
  43. aury/agents/cli/config.py +236 -0
  44. aury/agents/cli/extensions.py +460 -0
  45. aury/agents/cli/main.py +189 -0
  46. aury/agents/cli/session.py +337 -0
  47. aury/agents/cli/workflow.py +276 -0
  48. aury/agents/context_providers/__init__.py +66 -0
  49. aury/agents/context_providers/artifact.py +299 -0
  50. aury/agents/context_providers/base.py +177 -0
  51. aury/agents/context_providers/memory.py +70 -0
  52. aury/agents/context_providers/message.py +130 -0
  53. aury/agents/context_providers/skill.py +50 -0
  54. aury/agents/context_providers/subagent.py +46 -0
  55. aury/agents/context_providers/tool.py +68 -0
  56. aury/agents/core/__init__.py +83 -0
  57. aury/agents/core/base.py +573 -0
  58. aury/agents/core/context.py +797 -0
  59. aury/agents/core/context_builder.py +303 -0
  60. aury/agents/core/event_bus/__init__.py +15 -0
  61. aury/agents/core/event_bus/bus.py +203 -0
  62. aury/agents/core/factory.py +169 -0
  63. aury/agents/core/isolator.py +97 -0
  64. aury/agents/core/logging.py +95 -0
  65. aury/agents/core/parallel.py +194 -0
  66. aury/agents/core/runner.py +139 -0
  67. aury/agents/core/services/__init__.py +5 -0
  68. aury/agents/core/services/file_session.py +144 -0
  69. aury/agents/core/services/message.py +53 -0
  70. aury/agents/core/services/session.py +53 -0
  71. aury/agents/core/signals.py +109 -0
  72. aury/agents/core/state.py +363 -0
  73. aury/agents/core/types/__init__.py +107 -0
  74. aury/agents/core/types/action.py +176 -0
  75. aury/agents/core/types/artifact.py +135 -0
  76. aury/agents/core/types/block.py +736 -0
  77. aury/agents/core/types/message.py +350 -0
  78. aury/agents/core/types/recall.py +144 -0
  79. aury/agents/core/types/session.py +257 -0
  80. aury/agents/core/types/subagent.py +154 -0
  81. aury/agents/core/types/tool.py +205 -0
  82. aury/agents/eval/__init__.py +331 -0
  83. aury/agents/hitl/__init__.py +57 -0
  84. aury/agents/hitl/ask_user.py +242 -0
  85. aury/agents/hitl/compaction.py +230 -0
  86. aury/agents/hitl/exceptions.py +87 -0
  87. aury/agents/hitl/permission.py +617 -0
  88. aury/agents/hitl/revert.py +216 -0
  89. aury/agents/llm/__init__.py +31 -0
  90. aury/agents/llm/adapter.py +367 -0
  91. aury/agents/llm/openai.py +294 -0
  92. aury/agents/llm/provider.py +476 -0
  93. aury/agents/mcp/__init__.py +153 -0
  94. aury/agents/memory/__init__.py +46 -0
  95. aury/agents/memory/compaction.py +394 -0
  96. aury/agents/memory/manager.py +465 -0
  97. aury/agents/memory/processor.py +177 -0
  98. aury/agents/memory/store.py +187 -0
  99. aury/agents/memory/types.py +137 -0
  100. aury/agents/messages/__init__.py +40 -0
  101. aury/agents/messages/config.py +47 -0
  102. aury/agents/messages/raw_store.py +224 -0
  103. aury/agents/messages/store.py +118 -0
  104. aury/agents/messages/types.py +88 -0
  105. aury/agents/middleware/__init__.py +31 -0
  106. aury/agents/middleware/base.py +341 -0
  107. aury/agents/middleware/chain.py +342 -0
  108. aury/agents/middleware/message.py +129 -0
  109. aury/agents/middleware/message_container.py +126 -0
  110. aury/agents/middleware/raw_message.py +153 -0
  111. aury/agents/middleware/truncation.py +139 -0
  112. aury/agents/middleware/types.py +81 -0
  113. aury/agents/plugin.py +162 -0
  114. aury/agents/react/__init__.py +4 -0
  115. aury/agents/react/agent.py +1923 -0
  116. aury/agents/sandbox/__init__.py +23 -0
  117. aury/agents/sandbox/local.py +239 -0
  118. aury/agents/sandbox/remote.py +200 -0
  119. aury/agents/sandbox/types.py +115 -0
  120. aury/agents/skill/__init__.py +16 -0
  121. aury/agents/skill/loader.py +180 -0
  122. aury/agents/skill/types.py +83 -0
  123. aury/agents/tool/__init__.py +39 -0
  124. aury/agents/tool/builtin/__init__.py +23 -0
  125. aury/agents/tool/builtin/ask_user.py +155 -0
  126. aury/agents/tool/builtin/bash.py +107 -0
  127. aury/agents/tool/builtin/delegate.py +726 -0
  128. aury/agents/tool/builtin/edit.py +121 -0
  129. aury/agents/tool/builtin/plan.py +277 -0
  130. aury/agents/tool/builtin/read.py +91 -0
  131. aury/agents/tool/builtin/thinking.py +111 -0
  132. aury/agents/tool/builtin/yield_result.py +130 -0
  133. aury/agents/tool/decorator.py +252 -0
  134. aury/agents/tool/set.py +204 -0
  135. aury/agents/usage/__init__.py +12 -0
  136. aury/agents/usage/tracker.py +236 -0
  137. aury/agents/workflow/__init__.py +85 -0
  138. aury/agents/workflow/adapter.py +268 -0
  139. aury/agents/workflow/dag.py +116 -0
  140. aury/agents/workflow/dsl.py +575 -0
  141. aury/agents/workflow/executor.py +659 -0
  142. aury/agents/workflow/expression.py +136 -0
  143. aury/agents/workflow/parser.py +182 -0
  144. aury/agents/workflow/state.py +145 -0
  145. aury/agents/workflow/types.py +86 -0
  146. aury_agent-0.0.4.dist-info/METADATA +90 -0
  147. aury_agent-0.0.4.dist-info/RECORD +149 -0
  148. aury_agent-0.0.4.dist-info/WHEEL +4 -0
  149. aury_agent-0.0.4.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,180 @@
1
+ """SkillLoader - Load skills from filesystem."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ import yaml
10
+
11
+ from .types import Skill
12
+
13
+
14
+ class SkillLoader:
15
+ """Load and manage skills from the filesystem.
16
+
17
+ Skills are loaded from SKILL.md files with priority:
18
+ 1. project/ - Project-level skills (.aury/skills/)
19
+ 2. user/ - User-level skills (~/.aury/skills/)
20
+ 3. system/ - Built-in skills (framework provided)
21
+
22
+ Same-named skills: higher priority overrides lower.
23
+
24
+ Usage:
25
+ loader = SkillLoader()
26
+ await loader.load_from_directory(Path(".aury/skills"), source="project")
27
+
28
+ skill = loader.get("code-review")
29
+ all_skills = loader.list_all()
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ self._skills: dict[str, Skill] = {}
34
+
35
+ async def load_from_directory(
36
+ self,
37
+ directory: Path,
38
+ source: Literal["user", "project", "system"] = "user",
39
+ ) -> list[Skill]:
40
+ """Load skills from a directory.
41
+
42
+ Scans for SKILL.md files and loads them.
43
+
44
+ Args:
45
+ directory: Directory to scan
46
+ source: Source identifier for loaded skills
47
+
48
+ Returns:
49
+ List of loaded skills
50
+ """
51
+ loaded: list[Skill] = []
52
+
53
+ if not directory.exists():
54
+ return loaded
55
+
56
+ for skill_md in directory.rglob("SKILL.md"):
57
+ try:
58
+ skill = await self._load_skill(skill_md, source)
59
+ if skill:
60
+ if skill.name not in self._skills:
61
+ self._skills[skill.name] = skill
62
+ loaded.append(skill)
63
+ except Exception:
64
+ continue
65
+
66
+ return loaded
67
+
68
+ async def _load_skill(
69
+ self,
70
+ path: Path,
71
+ source: Literal["user", "project", "system"],
72
+ ) -> Skill | None:
73
+ """Load a single skill from SKILL.md."""
74
+ def _parse() -> tuple[dict[str, Any], str] | None:
75
+ text = path.read_text(encoding="utf-8")
76
+
77
+ if not text.startswith("---"):
78
+ return None
79
+
80
+ parts = text.split("---", 2)
81
+ if len(parts) < 3:
82
+ return None
83
+
84
+ frontmatter = yaml.safe_load(parts[1])
85
+ if not frontmatter:
86
+ return None
87
+
88
+ return frontmatter, parts[2].strip()
89
+
90
+ loop = asyncio.get_event_loop()
91
+ result = await loop.run_in_executor(None, _parse)
92
+
93
+ if not result:
94
+ return None
95
+
96
+ frontmatter, body = result
97
+
98
+ name = frontmatter.get("name")
99
+ description = frontmatter.get("description")
100
+
101
+ if not name or not description:
102
+ return None
103
+
104
+ if not re.match(r"^[a-z][a-z0-9-]*$", name) or len(name) > 64:
105
+ return None
106
+
107
+ allowed_tools = None
108
+ if "allowed_tools" in frontmatter:
109
+ tools_str = frontmatter["allowed_tools"]
110
+ if isinstance(tools_str, str):
111
+ allowed_tools = tools_str.split()
112
+ elif isinstance(tools_str, list):
113
+ allowed_tools = tools_str
114
+
115
+ return Skill(
116
+ name=name,
117
+ description=description.strip(),
118
+ path=path,
119
+ source=source,
120
+ license=frontmatter.get("license"),
121
+ allowed_tools=allowed_tools,
122
+ metadata={k: v for k, v in frontmatter.items()
123
+ if k not in ("name", "description", "license", "allowed_tools")},
124
+ _content=body,
125
+ )
126
+
127
+ def get(self, name: str) -> Skill | None:
128
+ """Get skill by name."""
129
+ return self._skills.get(name)
130
+
131
+ def list_all(self) -> list[Skill]:
132
+ """Get all registered skills."""
133
+ return list(self._skills.values())
134
+
135
+ def match(self, query: str) -> list[Skill]:
136
+ """Fuzzy match skills based on description.
137
+
138
+ Simple keyword matching - can be enhanced with embeddings.
139
+ """
140
+ query_lower = query.lower()
141
+ matches = []
142
+
143
+ for skill in self._skills.values():
144
+ desc_lower = skill.description.lower()
145
+ name_lower = skill.name.lower()
146
+
147
+ query_words = query_lower.split()
148
+ if any(word in desc_lower or word in name_lower for word in query_words):
149
+ matches.append(skill)
150
+
151
+ return matches
152
+
153
+ def format_for_prompt(self) -> str:
154
+ """Format skills for system prompt injection.
155
+
156
+ Returns a markdown-formatted list of available skills.
157
+ """
158
+ if not self._skills:
159
+ return ""
160
+
161
+ lines = ["## Available Skills\n"]
162
+
163
+ for skill in sorted(self._skills.values(), key=lambda s: s.name):
164
+ desc_first_line = skill.description.split("\n")[0].strip()
165
+ lines.append(f"- **{skill.name}**: {desc_first_line}")
166
+ lines.append(f" Path: `{skill.path.parent}`")
167
+
168
+ if skill.allowed_tools:
169
+ lines.append(f" Tools: {', '.join(skill.allowed_tools)}")
170
+
171
+ lines.append("")
172
+
173
+ return "\n".join(lines)
174
+
175
+ def clear(self) -> None:
176
+ """Clear all loaded skills."""
177
+ self._skills.clear()
178
+
179
+
180
+ __all__ = ["SkillLoader"]
@@ -0,0 +1,83 @@
1
+ """Skill data types."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+
10
+ @dataclass
11
+ class Skill:
12
+ """Skill definition.
13
+
14
+ A skill represents a reusable capability that can be loaded into an agent.
15
+ Skills are defined by SKILL.md files with YAML frontmatter.
16
+
17
+ Attributes:
18
+ name: Unique identifier (lowercase + hyphens, ≤64 chars)
19
+ description: Description and trigger conditions (≤1024 chars)
20
+ path: Path to SKILL.md file
21
+ source: Origin of the skill (user/project/system)
22
+ license: Optional license
23
+ allowed_tools: Pre-authorized tools for this skill
24
+ metadata: Additional metadata from frontmatter
25
+ """
26
+ name: str
27
+ description: str
28
+ path: Path
29
+ source: Literal["user", "project", "system"]
30
+
31
+ # Optional metadata
32
+ license: str | None = None
33
+ allowed_tools: list[str] | None = None
34
+ metadata: dict[str, Any] = field(default_factory=dict)
35
+
36
+ # Runtime (lazy loaded)
37
+ _content: str | None = field(default=None, repr=False)
38
+
39
+ async def load_content(self) -> str:
40
+ """Load full content (Progressive Disclosure).
41
+
42
+ Lazily loads the body content of SKILL.md when needed.
43
+ """
44
+ if self._content is None:
45
+ self._content = await self._read_body()
46
+ return self._content
47
+
48
+ async def _read_body(self) -> str:
49
+ """Read SKILL.md body (after frontmatter)."""
50
+ def _read() -> str:
51
+ text = self.path.read_text(encoding="utf-8")
52
+ if text.startswith("---"):
53
+ parts = text.split("---", 2)
54
+ if len(parts) >= 3:
55
+ return parts[2].strip()
56
+ return text
57
+
58
+ loop = asyncio.get_event_loop()
59
+ return await loop.run_in_executor(None, _read)
60
+
61
+ def get_resource_path(self, relative: str) -> Path:
62
+ """Get absolute path for a resource relative to skill directory.
63
+
64
+ Args:
65
+ relative: Relative path (e.g., "scripts/helper.py")
66
+
67
+ Returns:
68
+ Absolute path to the resource
69
+ """
70
+ return self.path.parent / relative
71
+
72
+ def has_script(self, name: str) -> bool:
73
+ """Check if skill has a specific script."""
74
+ script_path = self.get_resource_path(f"scripts/{name}")
75
+ return script_path.exists()
76
+
77
+ def has_reference(self, name: str) -> bool:
78
+ """Check if skill has a specific reference document."""
79
+ ref_path = self.get_resource_path(f"references/{name}")
80
+ return ref_path.exists()
81
+
82
+
83
+ __all__ = ["Skill"]
@@ -0,0 +1,39 @@
1
+ """Tool system with ToolSet, decorators, and built-in tools."""
2
+ from .set import ToolSet
3
+ from .decorator import tool
4
+ from .builtin import (
5
+ PlanTool,
6
+ DelegateTool,
7
+ YieldResultTool,
8
+ AskUserTool,
9
+ ThinkingTool,
10
+ )
11
+ from ..core.types.tool import (
12
+ BaseTool,
13
+ ToolInfo,
14
+ ToolContext,
15
+ ToolResult,
16
+ ToolConfig,
17
+ ToolInvocation,
18
+ ToolInvocationState,
19
+ )
20
+
21
+ __all__ = [
22
+ # Base classes
23
+ "BaseTool",
24
+ "ToolInfo",
25
+ "ToolContext",
26
+ "ToolResult",
27
+ "ToolConfig",
28
+ "ToolInvocation",
29
+ "ToolInvocationState",
30
+ # ToolSet and decorators
31
+ "ToolSet",
32
+ "tool",
33
+ # Built-in tools
34
+ "PlanTool",
35
+ "DelegateTool",
36
+ "YieldResultTool",
37
+ "AskUserTool",
38
+ "ThinkingTool",
39
+ ]
@@ -0,0 +1,23 @@
1
+ """Built-in tools for agents."""
2
+ from .plan import PlanTool
3
+ from .delegate import DelegateTool
4
+ from .yield_result import YieldResultTool
5
+ from .ask_user import AskUserTool
6
+ from .thinking import ThinkingTool
7
+ from .bash import BashTool
8
+ from .read import ReadTool
9
+ from .edit import EditTool
10
+
11
+ __all__ = [
12
+ # Control flow tools
13
+ "PlanTool",
14
+ "DelegateTool",
15
+ "YieldResultTool",
16
+ # HITL tools
17
+ "AskUserTool",
18
+ "ThinkingTool",
19
+ # File/Shell tools
20
+ "BashTool",
21
+ "ReadTool",
22
+ "EditTool",
23
+ ]
@@ -0,0 +1,155 @@
1
+ """Ask user tool - human-in-the-loop interaction.
2
+
3
+ Enables agent to request clarification or confirmation from user.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Literal
8
+
9
+ from ...core.logging import tool_logger as logger
10
+ from ...core.types.tool import BaseTool, ToolContext, ToolResult
11
+ from ...core.types.block import BlockEvent, BlockKind, BlockOp
12
+
13
+
14
+ QuestionType = Literal["text", "confirm", "choice"]
15
+
16
+
17
+ class AskUserTool(BaseTool):
18
+ """Request input from the user.
19
+
20
+ Use this tool when you need:
21
+ - Clarification on ambiguous requirements
22
+ - Confirmation before destructive operations
23
+ - User to make a choice between options
24
+ - Additional information not available in context
25
+
26
+ The tool will pause execution and wait for user response.
27
+ """
28
+
29
+ _name = "ask_user"
30
+
31
+ @property
32
+ def name(self) -> str:
33
+ return self._name
34
+
35
+ @property
36
+ def description(self) -> str:
37
+ return """Ask the user a question and wait for their response.
38
+
39
+ Use this when you need clarification, confirmation, or additional
40
+ information from the user. Supports:
41
+ - text: Open-ended questions
42
+ - confirm: Yes/No questions
43
+ - choice: Multiple choice selection
44
+
45
+ The conversation will pause until the user responds."""
46
+
47
+ @property
48
+ def parameters(self) -> dict[str, Any]:
49
+ return {
50
+ "type": "object",
51
+ "properties": {
52
+ "question": {
53
+ "type": "string",
54
+ "description": "The question to ask the user",
55
+ },
56
+ "type": {
57
+ "type": "string",
58
+ "enum": ["text", "confirm", "choice"],
59
+ "description": "Type of response expected",
60
+ "default": "text",
61
+ },
62
+ "choices": {
63
+ "type": "array",
64
+ "items": {"type": "string"},
65
+ "description": "Options for 'choice' type questions",
66
+ },
67
+ "default": {
68
+ "type": "string",
69
+ "description": "Default value if user skips",
70
+ },
71
+ "context": {
72
+ "type": "string",
73
+ "description": "Additional context to help user answer",
74
+ },
75
+ },
76
+ "required": ["question"],
77
+ }
78
+
79
+ async def execute(
80
+ self,
81
+ params: dict[str, Any],
82
+ ctx: ToolContext,
83
+ ) -> ToolResult:
84
+ """Execute ask - request user input."""
85
+ question = params.get("question", "")
86
+ q_type: QuestionType = params.get("type", "text")
87
+ choices = params.get("choices", [])
88
+ default = params.get("default")
89
+ context = params.get("context")
90
+
91
+ logger.info(
92
+ "Asking user",
93
+ extra={"question": question[:50], "type": q_type},
94
+ )
95
+
96
+ # Validate choice type has choices
97
+ if q_type == "choice" and not choices:
98
+ return ToolResult.error("Choice type questions require 'choices' parameter")
99
+
100
+ # Emit ASK block
101
+ await self._emit_ask_block(ctx, question, q_type, choices, default, context)
102
+
103
+ # Build display output
104
+ output_parts = [f"Question: {question}"]
105
+ if context:
106
+ output_parts.append(f"Context: {context}")
107
+ if q_type == "confirm":
108
+ output_parts.append("Type: Yes/No")
109
+ elif q_type == "choice" and choices:
110
+ output_parts.append("Options:")
111
+ for i, choice in enumerate(choices, 1):
112
+ output_parts.append(f" {i}. {choice}")
113
+ if default:
114
+ output_parts.append(f"Default: {default}")
115
+
116
+ # Note: Real implementation would:
117
+ # 1. Emit HITL block/event
118
+ # 2. Set invocation state to "waiting_for_input"
119
+ # 3. Pause agent execution
120
+ # 4. When user responds, resume with answer in context
121
+
122
+ return ToolResult(output="\n".join(output_parts))
123
+
124
+ async def _emit_ask_block(
125
+ self,
126
+ ctx: ToolContext,
127
+ question: str,
128
+ q_type: QuestionType,
129
+ choices: list[str],
130
+ default: str | None,
131
+ context: str | None,
132
+ ) -> None:
133
+ """Emit ASK block."""
134
+ emit = getattr(ctx, 'emit', None)
135
+ if emit is None:
136
+ return
137
+
138
+ block = BlockEvent(
139
+ kind=BlockKind.ASK,
140
+ op=BlockOp.APPLY,
141
+ data={
142
+ "question": question,
143
+ "type": q_type,
144
+ "choices": choices,
145
+ "default": default,
146
+ "context": context,
147
+ },
148
+ session_id=ctx.session_id,
149
+ invocation_id=ctx.invocation_id,
150
+ )
151
+
152
+ await emit(block)
153
+
154
+
155
+ __all__ = ["AskUserTool"]
@@ -0,0 +1,107 @@
1
+ """BashTool - Execute shell commands."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import subprocess
6
+ from typing import Any
7
+
8
+ from ...core.types.tool import BaseTool, ToolContext, ToolResult, ToolInfo
9
+
10
+
11
+ class BashTool(BaseTool):
12
+ """Execute shell commands.
13
+
14
+ Provides the ability to run shell commands with timeout and working directory support.
15
+
16
+ Security Note:
17
+ In production, you should restrict the commands that can be executed,
18
+ or run in a sandboxed environment.
19
+ """
20
+
21
+ _name = "bash"
22
+ _description = "Execute shell commands and return the output"
23
+ _parameters = {
24
+ "type": "object",
25
+ "properties": {
26
+ "command": {
27
+ "type": "string",
28
+ "description": "The shell command to execute",
29
+ },
30
+ "timeout": {
31
+ "type": "integer",
32
+ "description": "Timeout in seconds (default: 30)",
33
+ "default": 30,
34
+ },
35
+ "working_dir": {
36
+ "type": "string",
37
+ "description": "Working directory for the command (optional)",
38
+ },
39
+ },
40
+ "required": ["command"],
41
+ }
42
+
43
+ def __init__(self, allowed_commands: list[str] | None = None):
44
+ """Initialize BashTool.
45
+
46
+ Args:
47
+ allowed_commands: List of allowed command prefixes (None = allow all)
48
+ """
49
+ self._allowed_commands = allowed_commands
50
+
51
+ async def execute(
52
+ self,
53
+ params: dict[str, Any],
54
+ ctx: ToolContext,
55
+ ) -> ToolResult:
56
+ """Execute the shell command."""
57
+ command = params.get("command", "")
58
+ timeout = params.get("timeout", 30)
59
+ working_dir = params.get("working_dir")
60
+
61
+ if not command:
62
+ return ToolResult.error("Command is required")
63
+
64
+ # Security check
65
+ if self._allowed_commands:
66
+ cmd_prefix = command.split()[0] if command.split() else ""
67
+ if cmd_prefix not in self._allowed_commands:
68
+ return ToolResult.error(f"Command '{cmd_prefix}' is not allowed")
69
+
70
+ try:
71
+ # Run command
72
+ process = await asyncio.create_subprocess_shell(
73
+ command,
74
+ stdout=asyncio.subprocess.PIPE,
75
+ stderr=asyncio.subprocess.PIPE,
76
+ cwd=working_dir,
77
+ )
78
+
79
+ try:
80
+ stdout, stderr = await asyncio.wait_for(
81
+ process.communicate(),
82
+ timeout=timeout,
83
+ )
84
+ except asyncio.TimeoutError:
85
+ process.kill()
86
+ return ToolResult.error(f"Command timed out after {timeout} seconds")
87
+
88
+ output = stdout.decode("utf-8", errors="replace")
89
+ error_output = stderr.decode("utf-8", errors="replace")
90
+
91
+ if process.returncode != 0:
92
+ return ToolResult(
93
+ output=f"Exit code: {process.returncode}\n\nSTDOUT:\n{output}\n\nSTDERR:\n{error_output}",
94
+ is_error=True,
95
+ )
96
+
97
+ result_output = output
98
+ if error_output:
99
+ result_output += f"\n\nSTDERR:\n{error_output}"
100
+
101
+ return ToolResult(output=result_output.strip() or "(no output)")
102
+
103
+ except Exception as e:
104
+ return ToolResult.error(str(e))
105
+
106
+
107
+ __all__ = ["BashTool"]