techtide-swarm 0.1.0__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.
- techtide_swarm/__init__.py +30 -0
- techtide_swarm/agent.py +224 -0
- techtide_swarm/bash_gate.py +44 -0
- techtide_swarm/cli.py +1121 -0
- techtide_swarm/core/__init__.py +1 -0
- techtide_swarm/core/types.py +17 -0
- techtide_swarm/http_security.py +29 -0
- techtide_swarm/llm.py +78 -0
- techtide_swarm/memory.py +255 -0
- techtide_swarm/memvid_bridge.py +85 -0
- techtide_swarm/persistence.py +203 -0
- techtide_swarm/rate_limit.py +83 -0
- techtide_swarm/server.py +350 -0
- techtide_swarm/structured_logging.py +56 -0
- techtide_swarm/swarm.py +378 -0
- techtide_swarm/telemetry.py +108 -0
- techtide_swarm/tools/__init__.py +67 -0
- techtide_swarm/tools/file_ops.py +141 -0
- techtide_swarm/tools/mcp.py +348 -0
- techtide_swarm/tools/memory_tool.py +140 -0
- techtide_swarm/tools/registry.py +207 -0
- techtide_swarm/tools/terminal.py +70 -0
- techtide_swarm/tools/web_scrape.py +134 -0
- techtide_swarm/tools/web_search.py +131 -0
- techtide_swarm/ultra_plan.py +66 -0
- techtide_swarm-0.1.0.dist-info/METADATA +75 -0
- techtide_swarm-0.1.0.dist-info/RECORD +29 -0
- techtide_swarm-0.1.0.dist-info/WHEEL +4 -0
- techtide_swarm-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""TechTide Swarm 357 — public API."""
|
|
2
|
+
|
|
3
|
+
from techtide_swarm.agent import Agent, AgentConfig, AgentResult
|
|
4
|
+
from techtide_swarm.bash_gate import BashSecurityGate
|
|
5
|
+
from techtide_swarm.memory import MemoryManager
|
|
6
|
+
from techtide_swarm.memvid_bridge import MemvidBridge, MemvidBridgeError, resolve_bridge_binary
|
|
7
|
+
from techtide_swarm.persistence import SwarmStore
|
|
8
|
+
from techtide_swarm.swarm import CostController, Swarm, SwarmExecutionResult
|
|
9
|
+
from techtide_swarm.tools.registry import TOOLSET_MAP, ToolRegistry, registry
|
|
10
|
+
from techtide_swarm.ultra_plan import UltraPlan, UltraPlanConfig
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Agent",
|
|
14
|
+
"AgentConfig",
|
|
15
|
+
"AgentResult",
|
|
16
|
+
"BashSecurityGate",
|
|
17
|
+
"CostController",
|
|
18
|
+
"MemvidBridge",
|
|
19
|
+
"MemvidBridgeError",
|
|
20
|
+
"MemoryManager",
|
|
21
|
+
"Swarm",
|
|
22
|
+
"SwarmExecutionResult",
|
|
23
|
+
"SwarmStore",
|
|
24
|
+
"TOOLSET_MAP",
|
|
25
|
+
"ToolRegistry",
|
|
26
|
+
"UltraPlan",
|
|
27
|
+
"UltraPlanConfig",
|
|
28
|
+
"registry",
|
|
29
|
+
"resolve_bridge_binary",
|
|
30
|
+
]
|
techtide_swarm/agent.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Single-agent runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from techtide_swarm.core.types import LayerType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentConfig(BaseModel):
|
|
17
|
+
"""Configuration for one agent."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
layer: LayerType
|
|
21
|
+
role: str
|
|
22
|
+
soul: str = ""
|
|
23
|
+
tools: list[str] = Field(default_factory=list)
|
|
24
|
+
model: str = "sonnet"
|
|
25
|
+
budget_limit_usd: float = 1.0
|
|
26
|
+
max_turns: int = 10
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AgentResult:
|
|
31
|
+
"""Outcome of agent.run."""
|
|
32
|
+
|
|
33
|
+
output: str
|
|
34
|
+
cost_usd: float
|
|
35
|
+
latency_ms: int
|
|
36
|
+
status: str
|
|
37
|
+
agent_name: str = ""
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Agent:
|
|
42
|
+
"""One Claude-powered agent."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, config: AgentConfig) -> None:
|
|
45
|
+
self.config = config
|
|
46
|
+
|
|
47
|
+
def _load_system_prompt(self) -> str | None:
|
|
48
|
+
"""Read the SOUL.md template and return the system prompt body (after YAML front-matter)."""
|
|
49
|
+
soul_path = self.config.soul
|
|
50
|
+
if not soul_path:
|
|
51
|
+
return None
|
|
52
|
+
path = Path(soul_path)
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
# Resolve relative to repo root (two levels up from this package's src/)
|
|
55
|
+
pkg_src = Path(__file__).resolve().parent
|
|
56
|
+
candidates = [
|
|
57
|
+
pkg_src.parent.parent.parent.parent / soul_path, # repo root
|
|
58
|
+
pkg_src.parent.parent.parent / soul_path,
|
|
59
|
+
]
|
|
60
|
+
for candidate in candidates:
|
|
61
|
+
if candidate.is_file():
|
|
62
|
+
path = candidate
|
|
63
|
+
break
|
|
64
|
+
else:
|
|
65
|
+
return None
|
|
66
|
+
text = path.read_text(encoding="utf-8")
|
|
67
|
+
# Strip YAML front-matter delimited by ---
|
|
68
|
+
if text.startswith("---"):
|
|
69
|
+
parts = text.split("---", 2)
|
|
70
|
+
if len(parts) >= 3:
|
|
71
|
+
return parts[2].strip()
|
|
72
|
+
return text.strip()
|
|
73
|
+
|
|
74
|
+
async def run(self, task: str) -> AgentResult:
|
|
75
|
+
"""Execute task via Anthropic Messages API when API key is set; else deterministic stub."""
|
|
76
|
+
from techtide_swarm.llm import create_async_client, model_id as resolve_model_id, resolve_api_key
|
|
77
|
+
|
|
78
|
+
api_key = resolve_api_key()
|
|
79
|
+
if not api_key:
|
|
80
|
+
return self._stub_result(task)
|
|
81
|
+
|
|
82
|
+
started = time.perf_counter()
|
|
83
|
+
try:
|
|
84
|
+
from techtide_swarm.tools import get_anthropic_tools, execute_tool
|
|
85
|
+
|
|
86
|
+
client = create_async_client()
|
|
87
|
+
resolved_model = resolve_model_id(self.config.model)
|
|
88
|
+
system_prompt = self._load_system_prompt()
|
|
89
|
+
|
|
90
|
+
messages: list[dict[str, Any]] = [{"role": "user", "content": task}]
|
|
91
|
+
tools = get_anthropic_tools(self.config.tools)
|
|
92
|
+
|
|
93
|
+
total_cost = 0.0
|
|
94
|
+
final_text = ""
|
|
95
|
+
budget = self.config.budget_limit_usd
|
|
96
|
+
|
|
97
|
+
for turn in range(self.config.max_turns):
|
|
98
|
+
if total_cost >= budget:
|
|
99
|
+
final_text = f"Budget limit reached (${budget:.2f}). Stopping."
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
create_kwargs: dict[str, Any] = {
|
|
103
|
+
"model": resolved_model,
|
|
104
|
+
"max_tokens": 4096,
|
|
105
|
+
"messages": messages,
|
|
106
|
+
}
|
|
107
|
+
if system_prompt:
|
|
108
|
+
create_kwargs["system"] = system_prompt
|
|
109
|
+
if tools:
|
|
110
|
+
create_kwargs["tools"] = tools
|
|
111
|
+
|
|
112
|
+
message = await client.messages.create(**create_kwargs)
|
|
113
|
+
total_cost += self._estimate_cost(message, resolved_model)
|
|
114
|
+
|
|
115
|
+
messages.append({"role": "assistant", "content": message.content})
|
|
116
|
+
|
|
117
|
+
if total_cost >= budget:
|
|
118
|
+
final_text = self._extract_text(message) or (
|
|
119
|
+
f"Budget limit reached (${budget:.2f}). Stopping."
|
|
120
|
+
)
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
tool_uses = [block for block in message.content if getattr(block, "type", None) == "tool_use"]
|
|
124
|
+
|
|
125
|
+
if not tool_uses:
|
|
126
|
+
final_text = self._extract_text(message)
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
tool_results = []
|
|
130
|
+
for tool_use in tool_uses:
|
|
131
|
+
result_text = execute_tool(tool_use.name, tool_use.input)
|
|
132
|
+
tool_results.append({
|
|
133
|
+
"type": "tool_result",
|
|
134
|
+
"tool_use_id": tool_use.id,
|
|
135
|
+
"content": result_text
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
messages.append({"role": "user", "content": tool_results})
|
|
139
|
+
else:
|
|
140
|
+
final_text = "Max turns reached without completion."
|
|
141
|
+
|
|
142
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
143
|
+
|
|
144
|
+
from techtide_swarm.telemetry import log_telemetry
|
|
145
|
+
log_telemetry("agent_run", {
|
|
146
|
+
"agent_name": self.config.name,
|
|
147
|
+
"layer": self.config.layer.value,
|
|
148
|
+
"cost_usd": total_cost,
|
|
149
|
+
"latency_ms": elapsed_ms,
|
|
150
|
+
"status": "success",
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
return AgentResult(
|
|
154
|
+
agent_name=self.config.name,
|
|
155
|
+
output=final_text,
|
|
156
|
+
cost_usd=total_cost,
|
|
157
|
+
latency_ms=elapsed_ms,
|
|
158
|
+
status="success",
|
|
159
|
+
)
|
|
160
|
+
except Exception as exc: # noqa: BLE001 — surface to CLI
|
|
161
|
+
elapsed_ms = int((time.perf_counter() - started) * 1000)
|
|
162
|
+
|
|
163
|
+
from techtide_swarm.telemetry import log_telemetry
|
|
164
|
+
log_telemetry("agent_run", {
|
|
165
|
+
"agent_name": self.config.name,
|
|
166
|
+
"layer": self.config.layer.value,
|
|
167
|
+
"cost_usd": 0.0,
|
|
168
|
+
"latency_ms": elapsed_ms,
|
|
169
|
+
"status": "error",
|
|
170
|
+
"error": str(exc),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return AgentResult(
|
|
174
|
+
agent_name=self.config.name,
|
|
175
|
+
output="",
|
|
176
|
+
cost_usd=0.0,
|
|
177
|
+
latency_ms=elapsed_ms,
|
|
178
|
+
status="error",
|
|
179
|
+
error=str(exc),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _stub_result(self, task: str) -> AgentResult:
|
|
183
|
+
preview = task[:200].replace("\n", " ")
|
|
184
|
+
return AgentResult(
|
|
185
|
+
agent_name=self.config.name,
|
|
186
|
+
output=(
|
|
187
|
+
f"[stub] Agent {self.config.name} ({self.config.layer.value}) would process:\n"
|
|
188
|
+
f"{preview}"
|
|
189
|
+
),
|
|
190
|
+
cost_usd=0.0,
|
|
191
|
+
latency_ms=0,
|
|
192
|
+
status="success",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _model_id(short: str) -> str:
|
|
197
|
+
from techtide_swarm.llm import model_id as resolve_model_id
|
|
198
|
+
|
|
199
|
+
return resolve_model_id(short)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def _extract_text(message: Any) -> str:
|
|
203
|
+
parts: list[str] = []
|
|
204
|
+
for block in getattr(message, "content", []) or []:
|
|
205
|
+
btype = getattr(block, "type", None)
|
|
206
|
+
if btype == "text":
|
|
207
|
+
parts.append(getattr(block, "text", ""))
|
|
208
|
+
return "\n".join(parts) if parts else str(message)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _estimate_cost(message: Any, model_id: str) -> float:
|
|
212
|
+
usage = getattr(message, "usage", None)
|
|
213
|
+
if usage is None:
|
|
214
|
+
return 0.01
|
|
215
|
+
inp = float(getattr(usage, "input_tokens", 0) or 0)
|
|
216
|
+
out = float(getattr(usage, "output_tokens", 0) or 0)
|
|
217
|
+
# Approximate list pricing snapshot (Claude 4.x, 2026) — override with real billing later
|
|
218
|
+
if "opus" in model_id.lower():
|
|
219
|
+
rate_in, rate_out = 15.0 / 1e6, 75.0 / 1e6
|
|
220
|
+
elif "haiku" in model_id.lower():
|
|
221
|
+
rate_in, rate_out = 0.80 / 1e6, 4.0 / 1e6
|
|
222
|
+
else: # sonnet
|
|
223
|
+
rate_in, rate_out = 3.0 / 1e6, 15.0 / 1e6
|
|
224
|
+
return inp * rate_in + out * rate_out
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""23-point style bash command validation (subset implemented; extend as needed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BashSecurityGate:
|
|
10
|
+
"""Validate shell commands before execution."""
|
|
11
|
+
|
|
12
|
+
_BLOCK_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
13
|
+
(re.compile(r"(?i)\brm\s+(-[rf]*\s*)*[/~]"), "recursive delete toward root or home"),
|
|
14
|
+
(re.compile(r"(?i)\bcurl\b.*\|\s*bash"), "curl pipe to bash"),
|
|
15
|
+
(re.compile(r"(?i)\bwget\b.*\|\s*(?:sh|bash)"), "wget pipe to shell"),
|
|
16
|
+
(re.compile(r"(?i)\b(?:sudo\s+)?chmod\s+777\b"), "dangerous chmod"),
|
|
17
|
+
(re.compile(r"(?i)\$[\w_]*(?:KEY|SECRET|TOKEN|PASSWORD)\b"), "references secret env var"),
|
|
18
|
+
(re.compile(r"(?i)\b(?:ANTHROPIC|OPENAI|AWS|GCP)_(?:API_)?KEY\b"), "API key literal in argv"),
|
|
19
|
+
(re.compile(r"(?i)>\s*/etc/"), "redirect to system config"),
|
|
20
|
+
(re.compile(r"(?i)\bmkfs\b"), "disk format"),
|
|
21
|
+
(re.compile(r"(?i)\bdd\s+if="), "raw disk write"),
|
|
22
|
+
(re.compile(r"(?i)\bpython[23]?\s+-c\s+.*(?:exec|eval|import\s+os)"), "python code exec with os access"),
|
|
23
|
+
(re.compile(r"(?i)\bsudo\s+(?:rm|mv|cp|chown)\b"), "elevated file operation"),
|
|
24
|
+
(re.compile(r"(?i)\bnc\s+-[el]"), "netcat listener"),
|
|
25
|
+
(re.compile(r"(?i)>\s*/dev/(?:sd|hd|nvme)"), "redirect to block device"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def validate(cls, command: str) -> tuple[bool, str]:
|
|
30
|
+
"""Return (safe, reason). Safe commands get reason ''."""
|
|
31
|
+
stripped = command.strip()
|
|
32
|
+
if not stripped:
|
|
33
|
+
return False, "empty command"
|
|
34
|
+
try:
|
|
35
|
+
parts = shlex.split(stripped)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
return False, f"unparseable shell: {exc}"
|
|
38
|
+
if not parts:
|
|
39
|
+
return False, "empty command"
|
|
40
|
+
joined = " ".join(parts)
|
|
41
|
+
for pattern, reason in cls._BLOCK_PATTERNS:
|
|
42
|
+
if pattern.search(stripped) or pattern.search(joined):
|
|
43
|
+
return False, reason
|
|
44
|
+
return True, ""
|