sourcebot 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.
- sourcebot/__init__.py +9 -0
- sourcebot/__main__.py +17 -0
- sourcebot/bus/__init__.py +4 -0
- sourcebot/bus/channel_adapter.py +21 -0
- sourcebot/bus/event_bus.py +15 -0
- sourcebot/bus/message_models.py +33 -0
- sourcebot/bus/outbound_dispatcher.py +15 -0
- sourcebot/bus/session_manager.py +20 -0
- sourcebot/cli/commands/core/__init__.py +3 -0
- sourcebot/cli/commands/core/command_line.py +26 -0
- sourcebot/cli/commands/init_commands/__init__.py +3 -0
- sourcebot/cli/commands/init_commands/init_global_config.py +30 -0
- sourcebot/cli/commands/init_commands/init_workspace_config.py +18 -0
- sourcebot/cli/commands/run_commands/__init__.py +3 -0
- sourcebot/cli/commands/run_commands/command_line_tool.py +345 -0
- sourcebot/cli/commands/run_commands/safe_runner.py +47 -0
- sourcebot/cli/main.py +28 -0
- sourcebot/config/__init__.py +15 -0
- sourcebot/config/base.py +13 -0
- sourcebot/config/config_manager.py +367 -0
- sourcebot/config/exceptions.py +4 -0
- sourcebot/config/global_config.py +55 -0
- sourcebot/config/provider_config.py +62 -0
- sourcebot/config/workspace_config.py +106 -0
- sourcebot/context/__init__.py +5 -0
- sourcebot/context/context_builder.py +78 -0
- sourcebot/context/identity.py +19 -0
- sourcebot/context/message_builder.py +154 -0
- sourcebot/context/skill/__init__.py +7 -0
- sourcebot/context/skill/skill.py +11 -0
- sourcebot/context/skill/skill_context.py +10 -0
- sourcebot/context/skill/skill_loader.py +57 -0
- sourcebot/context/skill/skill_metadata.py +27 -0
- sourcebot/context/skill/skill_requirements.py +25 -0
- sourcebot/context/skill/skill_summary.py +31 -0
- sourcebot/conversation/__init__.py +2 -0
- sourcebot/conversation/service.py +191 -0
- sourcebot/docker_sandbox/__init__.py +3 -0
- sourcebot/docker_sandbox/docker_sandbox.py +113 -0
- sourcebot/llm/__init__.py +3 -0
- sourcebot/llm/anthropic/__init__.py +2 -0
- sourcebot/llm/anthropic/adapter.py +30 -0
- sourcebot/llm/anthropic/anthropic_llm_client.py +38 -0
- sourcebot/llm/anthropic/converter.py +59 -0
- sourcebot/llm/core/adapter.py +16 -0
- sourcebot/llm/core/client.py +16 -0
- sourcebot/llm/core/delta.py +12 -0
- sourcebot/llm/core/message.py +53 -0
- sourcebot/llm/core/message_converter.py +33 -0
- sourcebot/llm/core/response.py +30 -0
- sourcebot/llm/core/tool.py +7 -0
- sourcebot/llm/core/tool_converter.py +30 -0
- sourcebot/llm/core/tool_delta_aggregator.py +38 -0
- sourcebot/llm/llm_client_factory.py +13 -0
- sourcebot/llm/openai/__init__.py +2 -0
- sourcebot/llm/openai/adapter.py +27 -0
- sourcebot/llm/openai/converter.py +53 -0
- sourcebot/llm/openai/openai_llm_client.py +47 -0
- sourcebot/logging/__init__.py +3 -0
- sourcebot/logging/setup.py +33 -0
- sourcebot/memory/__init__.py +5 -0
- sourcebot/memory/file_store.py +23 -0
- sourcebot/memory/llm_consolidator.py +79 -0
- sourcebot/memory/service.py +116 -0
- sourcebot/memory/window_policy.py +36 -0
- sourcebot/prompt/__init__.py +4 -0
- sourcebot/prompt/deeomposer_prompt.py +420 -0
- sourcebot/prompt/identity_prompt.py +98 -0
- sourcebot/prompt/subagent_prompt.py +25 -0
- sourcebot/runtime/__init__.py +3 -0
- sourcebot/runtime/agent/__init__.py +3 -0
- sourcebot/runtime/agent/agent.py +130 -0
- sourcebot/runtime/agent/agent_factory.py +83 -0
- sourcebot/runtime/dag/planner/__init__.py +3 -0
- sourcebot/runtime/dag/planner/dag_planner.py +26 -0
- sourcebot/runtime/dag/planner/execution_scheduler.py +35 -0
- sourcebot/runtime/dag/planner/parallelism_optimizer.py +44 -0
- sourcebot/runtime/dag/planner/task_decomposer.py +37 -0
- sourcebot/runtime/dag/scheduler/__init__.py +3 -0
- sourcebot/runtime/dag/scheduler/dag_scheduler.py +319 -0
- sourcebot/runtime/dag/scheduler/retry_policy.py +27 -0
- sourcebot/runtime/dag/scheduler/run_store.py +58 -0
- sourcebot/runtime/dag/scheduler/state_store.py +40 -0
- sourcebot/runtime/dag/scheduler/task_graph.py +29 -0
- sourcebot/runtime/init_system.py +182 -0
- sourcebot/runtime/tool_executor.py +30 -0
- sourcebot/security/policy.py +23 -0
- sourcebot/session/__init__.py +4 -0
- sourcebot/session/jsonl_repository.py +142 -0
- sourcebot/session/repository.py +19 -0
- sourcebot/session/service.py +44 -0
- sourcebot/session/session.py +53 -0
- sourcebot/storage/__init__.py +3 -0
- sourcebot/storage/rules_loader.py +72 -0
- sourcebot/storage/skill_storage.py +51 -0
- sourcebot/tools/__init__.py +7 -0
- sourcebot/tools/base.py +182 -0
- sourcebot/tools/registry.py +81 -0
- sourcebot/tools/rule_detail.py +70 -0
- sourcebot/tools/rule_list.py +57 -0
- sourcebot/tools/shell.py +93 -0
- sourcebot/tools/skill_detail.py +61 -0
- sourcebot/tools/skill_list.py +68 -0
- sourcebot/utils/__init__.py +2 -0
- sourcebot/utils/output.py +79 -0
- sourcebot-0.1.0.dist-info/METADATA +318 -0
- sourcebot-0.1.0.dist-info/RECORD +110 -0
- sourcebot-0.1.0.dist-info/WHEEL +5 -0
- sourcebot-0.1.0.dist-info/entry_points.txt +2 -0
- sourcebot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# sourcebot/session/jsonl_repository.py
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from sourcebot.session.session import Session
|
|
10
|
+
from sourcebot.session.repository import SessionRepository
|
|
11
|
+
from sourcebot.llm.core.message import Message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JsonlSessionRepository(SessionRepository):
|
|
15
|
+
|
|
16
|
+
def __init__(self, workspace: Path):
|
|
17
|
+
self.workspace = workspace
|
|
18
|
+
self.sessions_dir = self.workspace / "sessions"
|
|
19
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
self.legacy_sessions_dir = Path.home() / ".etherealbot" / "sessions"
|
|
22
|
+
self._cache: dict[str, Session] = {}
|
|
23
|
+
|
|
24
|
+
# -------------------------
|
|
25
|
+
|
|
26
|
+
def _safe_filename(self, key: str) -> str:
|
|
27
|
+
return key.replace(":", "_").replace("/", "_")
|
|
28
|
+
|
|
29
|
+
def _get_session_path(self, key: str) -> Path:
|
|
30
|
+
safe_key = self._safe_filename(key)
|
|
31
|
+
return self.sessions_dir / f"{safe_key}.jsonl"
|
|
32
|
+
|
|
33
|
+
def _get_legacy_session_path(self, key: str) -> Path:
|
|
34
|
+
safe_key = self._safe_filename(key)
|
|
35
|
+
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
|
|
36
|
+
|
|
37
|
+
# -------------------------
|
|
38
|
+
|
|
39
|
+
def get_or_create(self, key: str) -> Session:
|
|
40
|
+
if key in self._cache:
|
|
41
|
+
return self._cache[key]
|
|
42
|
+
|
|
43
|
+
session = self._load(key)
|
|
44
|
+
if session is None:
|
|
45
|
+
session = Session(key=key)
|
|
46
|
+
|
|
47
|
+
self._cache[key] = session
|
|
48
|
+
return session
|
|
49
|
+
|
|
50
|
+
# -------------------------
|
|
51
|
+
|
|
52
|
+
def _load(self, key: str) -> Session | None:
|
|
53
|
+
path = self._get_session_path(key)
|
|
54
|
+
|
|
55
|
+
if not path.exists():
|
|
56
|
+
legacy = self._get_legacy_session_path(key)
|
|
57
|
+
if legacy.exists():
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
shutil.move(str(legacy), str(path))
|
|
60
|
+
|
|
61
|
+
if not path.exists():
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
messages = []
|
|
65
|
+
metadata: dict[str, Any] = {}
|
|
66
|
+
created_at = None
|
|
67
|
+
last_consolidated = 0
|
|
68
|
+
|
|
69
|
+
with open(path, encoding="utf-8") as f:
|
|
70
|
+
for line in f:
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if not line:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
data = json.loads(line)
|
|
76
|
+
|
|
77
|
+
if data.get("_type") == "metadata":
|
|
78
|
+
metadata = data.get("metadata", {})
|
|
79
|
+
created_at = (
|
|
80
|
+
datetime.fromisoformat(data["created_at"])
|
|
81
|
+
if data.get("created_at")
|
|
82
|
+
else None
|
|
83
|
+
)
|
|
84
|
+
last_consolidated = data.get("last_consolidated", 0)
|
|
85
|
+
else:
|
|
86
|
+
messages.append(Message.from_dict(data))
|
|
87
|
+
|
|
88
|
+
return Session(
|
|
89
|
+
key = key,
|
|
90
|
+
messages = messages,
|
|
91
|
+
created_at = created_at or datetime.now(),
|
|
92
|
+
metadata = metadata,
|
|
93
|
+
last_consolidated = last_consolidated,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# -------------------------
|
|
97
|
+
|
|
98
|
+
def save(self, session: Session) -> None:
|
|
99
|
+
path = self._get_session_path(session.key)
|
|
100
|
+
|
|
101
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
102
|
+
metadata_line = {
|
|
103
|
+
"_type": "metadata",
|
|
104
|
+
"key": session.key,
|
|
105
|
+
"created_at": session.created_at.isoformat(),
|
|
106
|
+
"updated_at": session.updated_at.isoformat(),
|
|
107
|
+
"metadata": session.metadata,
|
|
108
|
+
"last_consolidated": session.last_consolidated,
|
|
109
|
+
}
|
|
110
|
+
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
|
111
|
+
|
|
112
|
+
for msg in session.messages:
|
|
113
|
+
if hasattr(msg, "to_dict"):
|
|
114
|
+
data = msg.to_dict()
|
|
115
|
+
else:
|
|
116
|
+
data = msg # 兼容旧数据
|
|
117
|
+
f.write(json.dumps(data, ensure_ascii=False) + "\n")
|
|
118
|
+
|
|
119
|
+
self._cache[session.key] = session
|
|
120
|
+
|
|
121
|
+
# -------------------------
|
|
122
|
+
|
|
123
|
+
def list_sessions(self) -> list[dict]:
|
|
124
|
+
sessions = []
|
|
125
|
+
|
|
126
|
+
for path in self.sessions_dir.glob("*.jsonl"):
|
|
127
|
+
try:
|
|
128
|
+
with open(path, encoding="utf-8") as f:
|
|
129
|
+
first_line = f.readline().strip()
|
|
130
|
+
if first_line:
|
|
131
|
+
data = json.loads(first_line)
|
|
132
|
+
if data.get("_type") == "metadata":
|
|
133
|
+
sessions.append({
|
|
134
|
+
"key": data.get("key"),
|
|
135
|
+
"created_at": data.get("created_at"),
|
|
136
|
+
"updated_at": data.get("updated_at"),
|
|
137
|
+
"path": str(path),
|
|
138
|
+
})
|
|
139
|
+
except Exception:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
return sorted(sessions, key=lambda x: x.get("updated_at", ""), reverse=True)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# sourcebot/session/repository.py
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from sourcebot.session.session import Session
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SessionRepository(ABC):
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def get_or_create(self, key: str) -> Session:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def save(self, session: Session) -> None:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def list_sessions(self) -> list[dict]:
|
|
19
|
+
pass
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# sourcebot/session/service.py
|
|
2
|
+
|
|
3
|
+
from sourcebot.session.repository import SessionRepository
|
|
4
|
+
from typing import Any
|
|
5
|
+
class SessionService:
|
|
6
|
+
|
|
7
|
+
def __init__(self, repo: SessionRepository):
|
|
8
|
+
self.repo = repo
|
|
9
|
+
|
|
10
|
+
def append_user_message(self, key: str, content: str):
|
|
11
|
+
session = self.repo.get_or_create(key)
|
|
12
|
+
session.add_message("user", content)
|
|
13
|
+
self.repo.save(session)
|
|
14
|
+
|
|
15
|
+
def append_assistant_message(self, key: str, content: str, **kwargs: Any | None):
|
|
16
|
+
session = self.repo.get_or_create(key)
|
|
17
|
+
session.add_message("assistant", content, **kwargs)
|
|
18
|
+
self.repo.save(session)
|
|
19
|
+
# Session
|
|
20
|
+
def get_history(self, key: str):
|
|
21
|
+
session = self.repo.get_or_create(key)
|
|
22
|
+
return session
|
|
23
|
+
|
|
24
|
+
def list_sessions(self):
|
|
25
|
+
return self.repo.list_sessions()
|
|
26
|
+
|
|
27
|
+
def append_turn(
|
|
28
|
+
self,
|
|
29
|
+
key: str,
|
|
30
|
+
user: str,
|
|
31
|
+
assistant: str,
|
|
32
|
+
tools_used=None,
|
|
33
|
+
):
|
|
34
|
+
session = self.repo.get_or_create(key)
|
|
35
|
+
session.add_message("user", user)
|
|
36
|
+
session.add_message("assistant", assistant, tools_used = tools_used)
|
|
37
|
+
self.repo.save(session)
|
|
38
|
+
|
|
39
|
+
def save(self, session: Any) -> None:
|
|
40
|
+
|
|
41
|
+
return self.repo.save(session)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# sourcebot/session/session.py
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
from sourcebot.llm.core.message import Message
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Session:
|
|
9
|
+
key: str
|
|
10
|
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
11
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
12
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
13
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
last_consolidated: int = 0
|
|
15
|
+
|
|
16
|
+
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
|
|
17
|
+
msg = Message(
|
|
18
|
+
role = role,
|
|
19
|
+
content = content,
|
|
20
|
+
metadata = kwargs
|
|
21
|
+
)
|
|
22
|
+
self.messages.append(msg)
|
|
23
|
+
self.updated_at = datetime.now()
|
|
24
|
+
|
|
25
|
+
def get_history(self, max_messages: int = 500):
|
|
26
|
+
out = []
|
|
27
|
+
for m in self.messages[-max_messages:]:
|
|
28
|
+
entry = {
|
|
29
|
+
"role": m.role,
|
|
30
|
+
"content": m.content or "",
|
|
31
|
+
}
|
|
32
|
+
if m.tool_calls:
|
|
33
|
+
entry["tool_calls"] = [
|
|
34
|
+
{
|
|
35
|
+
"id": tc.id,
|
|
36
|
+
"type": "function",
|
|
37
|
+
"function": {
|
|
38
|
+
"name": tc.name,
|
|
39
|
+
"arguments": tc.arguments,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
for tc in m.tool_calls
|
|
43
|
+
]
|
|
44
|
+
if m.tool_results:
|
|
45
|
+
entry["tool_call_id"] = m.tool_results[0].tool_call_id
|
|
46
|
+
out.append(entry)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
def clear(self) -> None:
|
|
50
|
+
"""Clear all messages and reset the session to its initial state."""
|
|
51
|
+
self.messages = []
|
|
52
|
+
self.last_consolidated = 0
|
|
53
|
+
self.updated_at = datetime.now()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# sourcebot/storage/rules_loader.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
# logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
class RulesLoader:
|
|
8
|
+
"""Load rules files."""
|
|
9
|
+
def __init__(self, app_root_path: Path):
|
|
10
|
+
self.rules_path = [app_root_path/"rules"]
|
|
11
|
+
self.rules_common_path = app_root_path / "rules/common"
|
|
12
|
+
|
|
13
|
+
def load_common_rules(self) -> str:
|
|
14
|
+
"""Load all .md files from rules/common"""
|
|
15
|
+
parts = []
|
|
16
|
+
if self.rules_common_path.exists() and self.rules_common_path.is_dir():
|
|
17
|
+
for file_path in self.rules_common_path.glob("*.md"):
|
|
18
|
+
if file_path.is_file():
|
|
19
|
+
try:
|
|
20
|
+
content = file_path.read_text(encoding="utf-8")
|
|
21
|
+
parts.append(content)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.warning(f"Failed to read {file_path}: {e}")
|
|
24
|
+
return "\n\n".join(parts) if parts else ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_rules_dirs(self) -> List[str]:
|
|
28
|
+
'''List rules other than common.'''
|
|
29
|
+
result = set()
|
|
30
|
+
|
|
31
|
+
for root in self.rules_path:
|
|
32
|
+
if not root:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
root = Path(root).expanduser()
|
|
36
|
+
|
|
37
|
+
if not root.exists():
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
for d in root.iterdir():
|
|
41
|
+
if d.is_dir() and d.name != "common":
|
|
42
|
+
result.add(d.name)
|
|
43
|
+
|
|
44
|
+
return sorted(result)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_rule(self, name: str) -> Optional[str]:
|
|
48
|
+
"""Read by name rules + common."""
|
|
49
|
+
contents = []
|
|
50
|
+
|
|
51
|
+
for root in self.rules_path:
|
|
52
|
+
root = Path(root).expanduser()
|
|
53
|
+
|
|
54
|
+
for rule_name in ["common", name]:
|
|
55
|
+
|
|
56
|
+
target_dir = root / rule_name
|
|
57
|
+
|
|
58
|
+
if not target_dir.is_dir():
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
for md in sorted(target_dir.glob("*.md")):
|
|
62
|
+
try:
|
|
63
|
+
text = md.read_text("utf-8")
|
|
64
|
+
contents.append(f"# {md.stem}\n\n{text}")
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
if contents:
|
|
69
|
+
return "\n\n---\n\n".join(contents)
|
|
70
|
+
|
|
71
|
+
return None
|
|
72
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# sourcebot/storage/skill_storage.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, List, Tuple
|
|
4
|
+
import shutil
|
|
5
|
+
class SkillStorage:
|
|
6
|
+
"""Read skill markdown files from disk."""
|
|
7
|
+
SKILL_FILE = "SKILL.md"
|
|
8
|
+
|
|
9
|
+
def __init__(self, root_skills: Path, builtin_skills: Optional[Path] = None):
|
|
10
|
+
self.root_skills = root_skills
|
|
11
|
+
self.builtin_skills = builtin_skills
|
|
12
|
+
|
|
13
|
+
def list_skill_dirs(self, source: str) -> List[str]:
|
|
14
|
+
'''List skills.'''
|
|
15
|
+
result = []
|
|
16
|
+
for skills_dir in [self.root_skills, self.builtin_skills]:
|
|
17
|
+
if skills_dir and skills_dir.exists():
|
|
18
|
+
for d in skills_dir.iterdir():
|
|
19
|
+
if d.is_dir() and (d / self.SKILL_FILE).exists():
|
|
20
|
+
result.append((d.name, d / self.SKILL_FILE, source))
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
def list_skill_name(self, source: str) -> List[str]:
|
|
24
|
+
'''List skills name.'''
|
|
25
|
+
result = []
|
|
26
|
+
for skills_dir in [self.root_skills, self.builtin_skills]:
|
|
27
|
+
if skills_dir and skills_dir.exists():
|
|
28
|
+
for d in skills_dir.iterdir():
|
|
29
|
+
if d.is_dir() and (d / self.SKILL_FILE).exists():
|
|
30
|
+
result.append(d.name)
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
def read_skill(self, name: str) -> Optional[str]:
|
|
34
|
+
"""Read by name skill."""
|
|
35
|
+
for skills_dir in [self.root_skills, self.builtin_skills]:
|
|
36
|
+
if skills_dir and skills_dir.exists():
|
|
37
|
+
f = skills_dir / name / self.SKILL_FILE
|
|
38
|
+
if f.exists():
|
|
39
|
+
return f.read_text(encoding="utf-8")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def inject_skill(self, name: str, workspace: Path):
|
|
43
|
+
"""Inject skills into the workspace's skills directory"""
|
|
44
|
+
skill_dir = self.root_skills / name
|
|
45
|
+
if not skill_dir.exists():
|
|
46
|
+
return
|
|
47
|
+
target = workspace / "skills" / name
|
|
48
|
+
|
|
49
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
shutil.copytree(skill_dir, target, dirs_exist_ok=True)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from sourcebot.tools.shell import ShellTool
|
|
2
|
+
from sourcebot.tools.registry import ToolRegistry
|
|
3
|
+
from sourcebot.tools.skill_list import SkillListTool
|
|
4
|
+
from sourcebot.tools.skill_detail import SkillDetailTool
|
|
5
|
+
from sourcebot.tools.rule_list import RuleListTool
|
|
6
|
+
from sourcebot.tools.rule_detail import RuleDetailTool
|
|
7
|
+
__all__ = ["ShellTool", "ToolRegistry", "SkillListTool", "SkillDetailTool", "RuleListTool", "RuleDetailTool"]
|
sourcebot/tools/base.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# sourcebot/tools/base.py
|
|
2
|
+
"""Base class for agent tools."""
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Tool(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract base class for agent tools.
|
|
11
|
+
|
|
12
|
+
Tools are capabilities that the agent can use to interact with
|
|
13
|
+
the environment, such as reading files, executing commands, etc.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
_TYPE_MAP = {
|
|
17
|
+
"string": str,
|
|
18
|
+
"integer": int,
|
|
19
|
+
"number": (int, float),
|
|
20
|
+
"boolean": bool,
|
|
21
|
+
"array": list,
|
|
22
|
+
"object": dict,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def name(self) -> str:
|
|
28
|
+
"""Tool name used in function calls."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def description(self) -> str:
|
|
34
|
+
"""Description of what the tool does."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def parameters(self) -> dict[str, Any]:
|
|
40
|
+
"""JSON Schema for tool parameters."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def execute(self, **kwargs: Any) -> str:
|
|
45
|
+
"""
|
|
46
|
+
Execute the tool with given parameters.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
**kwargs: Tool-specific parameters.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
String result of the tool execution.
|
|
53
|
+
"""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
57
|
+
"""Apply safe schema-driven casts before validation."""
|
|
58
|
+
schema = self.parameters or {}
|
|
59
|
+
if schema.get("type", "object") != "object":
|
|
60
|
+
return params
|
|
61
|
+
|
|
62
|
+
return self._cast_object(params, schema)
|
|
63
|
+
|
|
64
|
+
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
"""Cast an object (dict) according to schema."""
|
|
66
|
+
if not isinstance(obj, dict):
|
|
67
|
+
return obj
|
|
68
|
+
|
|
69
|
+
props = schema.get("properties", {})
|
|
70
|
+
result = {}
|
|
71
|
+
|
|
72
|
+
for key, value in obj.items():
|
|
73
|
+
if key in props:
|
|
74
|
+
result[key] = self._cast_value(value, props[key])
|
|
75
|
+
else:
|
|
76
|
+
result[key] = value
|
|
77
|
+
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
def _cast_value(self, val: Any, schema: dict[str, Any]) -> Any:
|
|
81
|
+
"""Cast a single value according to schema."""
|
|
82
|
+
target_type = schema.get("type")
|
|
83
|
+
|
|
84
|
+
if target_type == "boolean" and isinstance(val, bool):
|
|
85
|
+
return val
|
|
86
|
+
if target_type == "integer" and isinstance(val, int) and not isinstance(val, bool):
|
|
87
|
+
return val
|
|
88
|
+
if target_type in self._TYPE_MAP and target_type not in ("boolean", "integer", "array", "object"):
|
|
89
|
+
expected = self._TYPE_MAP[target_type]
|
|
90
|
+
if isinstance(val, expected):
|
|
91
|
+
return val
|
|
92
|
+
|
|
93
|
+
if target_type == "integer" and isinstance(val, str):
|
|
94
|
+
try:
|
|
95
|
+
return int(val)
|
|
96
|
+
except ValueError:
|
|
97
|
+
return val
|
|
98
|
+
|
|
99
|
+
if target_type == "number" and isinstance(val, str):
|
|
100
|
+
try:
|
|
101
|
+
return float(val)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return val
|
|
104
|
+
|
|
105
|
+
if target_type == "string":
|
|
106
|
+
return val if val is None else str(val)
|
|
107
|
+
|
|
108
|
+
if target_type == "boolean" and isinstance(val, str):
|
|
109
|
+
val_lower = val.lower()
|
|
110
|
+
if val_lower in ("true", "1", "yes"):
|
|
111
|
+
return True
|
|
112
|
+
if val_lower in ("false", "0", "no"):
|
|
113
|
+
return False
|
|
114
|
+
return val
|
|
115
|
+
|
|
116
|
+
if target_type == "array" and isinstance(val, list):
|
|
117
|
+
item_schema = schema.get("items")
|
|
118
|
+
return [self._cast_value(item, item_schema) for item in val] if item_schema else val
|
|
119
|
+
|
|
120
|
+
if target_type == "object" and isinstance(val, dict):
|
|
121
|
+
return self._cast_object(val, schema)
|
|
122
|
+
|
|
123
|
+
return val
|
|
124
|
+
|
|
125
|
+
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
|
126
|
+
"""Validate tool parameters against JSON schema. Returns error list (empty if valid)."""
|
|
127
|
+
if not isinstance(params, dict):
|
|
128
|
+
return [f"parameters must be an object, got {type(params).__name__}"]
|
|
129
|
+
schema = self.parameters or {}
|
|
130
|
+
if schema.get("type", "object") != "object":
|
|
131
|
+
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
|
|
132
|
+
return self._validate(params, {**schema, "type": "object"}, "")
|
|
133
|
+
|
|
134
|
+
def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
|
|
135
|
+
t, label = schema.get("type"), path or "parameter"
|
|
136
|
+
if t == "integer" and (not isinstance(val, int) or isinstance(val, bool)):
|
|
137
|
+
return [f"{label} should be integer"]
|
|
138
|
+
if t == "number" and (
|
|
139
|
+
not isinstance(val, self._TYPE_MAP[t]) or isinstance(val, bool)
|
|
140
|
+
):
|
|
141
|
+
return [f"{label} should be number"]
|
|
142
|
+
if t in self._TYPE_MAP and t not in ("integer", "number") and not isinstance(val, self._TYPE_MAP[t]):
|
|
143
|
+
return [f"{label} should be {t}"]
|
|
144
|
+
|
|
145
|
+
errors = []
|
|
146
|
+
if "enum" in schema and val not in schema["enum"]:
|
|
147
|
+
errors.append(f"{label} must be one of {schema['enum']}")
|
|
148
|
+
if t in ("integer", "number"):
|
|
149
|
+
if "minimum" in schema and val < schema["minimum"]:
|
|
150
|
+
errors.append(f"{label} must be >= {schema['minimum']}")
|
|
151
|
+
if "maximum" in schema and val > schema["maximum"]:
|
|
152
|
+
errors.append(f"{label} must be <= {schema['maximum']}")
|
|
153
|
+
if t == "string":
|
|
154
|
+
if "minLength" in schema and len(val) < schema["minLength"]:
|
|
155
|
+
errors.append(f"{label} must be at least {schema['minLength']} chars")
|
|
156
|
+
if "maxLength" in schema and len(val) > schema["maxLength"]:
|
|
157
|
+
errors.append(f"{label} must be at most {schema['maxLength']} chars")
|
|
158
|
+
if t == "object":
|
|
159
|
+
props = schema.get("properties", {})
|
|
160
|
+
for k in schema.get("required", []):
|
|
161
|
+
if k not in val:
|
|
162
|
+
errors.append(f"missing required {path + '.' + k if path else k}")
|
|
163
|
+
for k, v in val.items():
|
|
164
|
+
if k in props:
|
|
165
|
+
errors.extend(self._validate(v, props[k], path + "." + k if path else k))
|
|
166
|
+
if t == "array" and "items" in schema:
|
|
167
|
+
for i, item in enumerate(val):
|
|
168
|
+
errors.extend(
|
|
169
|
+
self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")
|
|
170
|
+
)
|
|
171
|
+
return errors
|
|
172
|
+
|
|
173
|
+
def to_schema(self) -> dict[str, Any]:
|
|
174
|
+
"""Convert tool to OpenAI function schema format."""
|
|
175
|
+
return {
|
|
176
|
+
"type": "function",
|
|
177
|
+
"function": {
|
|
178
|
+
"name": self.name,
|
|
179
|
+
"description": self.description,
|
|
180
|
+
"parameters": self.parameters,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# sourcebot/tools/registry.py
|
|
2
|
+
"""Tool registry for dynamic tool management."""
|
|
3
|
+
from typing import Any
|
|
4
|
+
from sourcebot.tools.base import Tool
|
|
5
|
+
|
|
6
|
+
class ToolRegistry:
|
|
7
|
+
"""
|
|
8
|
+
Agent tool registry.
|
|
9
|
+
|
|
10
|
+
Allows dynamic registration and execution of tools.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._tools: dict[str, Tool] = {}
|
|
15
|
+
|
|
16
|
+
def register(self, tool: Tool) -> None:
|
|
17
|
+
"""Register a tool."""
|
|
18
|
+
self._tools[tool.name] = tool
|
|
19
|
+
|
|
20
|
+
def unregister(self, name: str) -> None:
|
|
21
|
+
"""Unregister a tool by name."""
|
|
22
|
+
self._tools.pop(name, None)
|
|
23
|
+
|
|
24
|
+
def get(self, name: str) -> Tool | None:
|
|
25
|
+
"""Get a tool by name."""
|
|
26
|
+
return self._tools.get(name)
|
|
27
|
+
|
|
28
|
+
def has(self, name: str) -> bool:
|
|
29
|
+
"""Check if a tool is registered."""
|
|
30
|
+
return name in self._tools
|
|
31
|
+
|
|
32
|
+
def get_definitions(self) -> list[dict[str, Any]]:
|
|
33
|
+
"""Get OpenAI format definitions for all tools."""
|
|
34
|
+
return [tool.to_schema() for tool in self._tools.values()]
|
|
35
|
+
|
|
36
|
+
async def execute(self, name: str, params: dict[str, Any]) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Execute a tool by name with the given parameters.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
name: Tool name.
|
|
42
|
+
params: Tool parameters.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Tool execution result (as string).
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
KeyError: If the specified tool is not found.
|
|
49
|
+
"""
|
|
50
|
+
tool = self._tools.get(name)
|
|
51
|
+
|
|
52
|
+
if not tool:
|
|
53
|
+
return f"Error: Tool '{name}' not found"
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
errors = tool.validate_params(params)
|
|
57
|
+
|
|
58
|
+
if errors:
|
|
59
|
+
return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
|
60
|
+
|
|
61
|
+
tool_call = {
|
|
62
|
+
"tool_name": name,
|
|
63
|
+
"tool_input": params,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
result = await tool.execute(**params)
|
|
67
|
+
|
|
68
|
+
return result
|
|
69
|
+
except Exception as e:
|
|
70
|
+
return f"Error executing {name}: {str(e)}"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def tool_names(self) -> list[str]:
|
|
74
|
+
"""Get list of registered tool names."""
|
|
75
|
+
return list(self._tools.keys())
|
|
76
|
+
|
|
77
|
+
def __len__(self) -> int:
|
|
78
|
+
return len(self._tools)
|
|
79
|
+
|
|
80
|
+
def __contains__(self, name: str) -> bool:
|
|
81
|
+
return name in self._tools
|