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.
Files changed (110) hide show
  1. sourcebot/__init__.py +9 -0
  2. sourcebot/__main__.py +17 -0
  3. sourcebot/bus/__init__.py +4 -0
  4. sourcebot/bus/channel_adapter.py +21 -0
  5. sourcebot/bus/event_bus.py +15 -0
  6. sourcebot/bus/message_models.py +33 -0
  7. sourcebot/bus/outbound_dispatcher.py +15 -0
  8. sourcebot/bus/session_manager.py +20 -0
  9. sourcebot/cli/commands/core/__init__.py +3 -0
  10. sourcebot/cli/commands/core/command_line.py +26 -0
  11. sourcebot/cli/commands/init_commands/__init__.py +3 -0
  12. sourcebot/cli/commands/init_commands/init_global_config.py +30 -0
  13. sourcebot/cli/commands/init_commands/init_workspace_config.py +18 -0
  14. sourcebot/cli/commands/run_commands/__init__.py +3 -0
  15. sourcebot/cli/commands/run_commands/command_line_tool.py +345 -0
  16. sourcebot/cli/commands/run_commands/safe_runner.py +47 -0
  17. sourcebot/cli/main.py +28 -0
  18. sourcebot/config/__init__.py +15 -0
  19. sourcebot/config/base.py +13 -0
  20. sourcebot/config/config_manager.py +367 -0
  21. sourcebot/config/exceptions.py +4 -0
  22. sourcebot/config/global_config.py +55 -0
  23. sourcebot/config/provider_config.py +62 -0
  24. sourcebot/config/workspace_config.py +106 -0
  25. sourcebot/context/__init__.py +5 -0
  26. sourcebot/context/context_builder.py +78 -0
  27. sourcebot/context/identity.py +19 -0
  28. sourcebot/context/message_builder.py +154 -0
  29. sourcebot/context/skill/__init__.py +7 -0
  30. sourcebot/context/skill/skill.py +11 -0
  31. sourcebot/context/skill/skill_context.py +10 -0
  32. sourcebot/context/skill/skill_loader.py +57 -0
  33. sourcebot/context/skill/skill_metadata.py +27 -0
  34. sourcebot/context/skill/skill_requirements.py +25 -0
  35. sourcebot/context/skill/skill_summary.py +31 -0
  36. sourcebot/conversation/__init__.py +2 -0
  37. sourcebot/conversation/service.py +191 -0
  38. sourcebot/docker_sandbox/__init__.py +3 -0
  39. sourcebot/docker_sandbox/docker_sandbox.py +113 -0
  40. sourcebot/llm/__init__.py +3 -0
  41. sourcebot/llm/anthropic/__init__.py +2 -0
  42. sourcebot/llm/anthropic/adapter.py +30 -0
  43. sourcebot/llm/anthropic/anthropic_llm_client.py +38 -0
  44. sourcebot/llm/anthropic/converter.py +59 -0
  45. sourcebot/llm/core/adapter.py +16 -0
  46. sourcebot/llm/core/client.py +16 -0
  47. sourcebot/llm/core/delta.py +12 -0
  48. sourcebot/llm/core/message.py +53 -0
  49. sourcebot/llm/core/message_converter.py +33 -0
  50. sourcebot/llm/core/response.py +30 -0
  51. sourcebot/llm/core/tool.py +7 -0
  52. sourcebot/llm/core/tool_converter.py +30 -0
  53. sourcebot/llm/core/tool_delta_aggregator.py +38 -0
  54. sourcebot/llm/llm_client_factory.py +13 -0
  55. sourcebot/llm/openai/__init__.py +2 -0
  56. sourcebot/llm/openai/adapter.py +27 -0
  57. sourcebot/llm/openai/converter.py +53 -0
  58. sourcebot/llm/openai/openai_llm_client.py +47 -0
  59. sourcebot/logging/__init__.py +3 -0
  60. sourcebot/logging/setup.py +33 -0
  61. sourcebot/memory/__init__.py +5 -0
  62. sourcebot/memory/file_store.py +23 -0
  63. sourcebot/memory/llm_consolidator.py +79 -0
  64. sourcebot/memory/service.py +116 -0
  65. sourcebot/memory/window_policy.py +36 -0
  66. sourcebot/prompt/__init__.py +4 -0
  67. sourcebot/prompt/deeomposer_prompt.py +420 -0
  68. sourcebot/prompt/identity_prompt.py +98 -0
  69. sourcebot/prompt/subagent_prompt.py +25 -0
  70. sourcebot/runtime/__init__.py +3 -0
  71. sourcebot/runtime/agent/__init__.py +3 -0
  72. sourcebot/runtime/agent/agent.py +130 -0
  73. sourcebot/runtime/agent/agent_factory.py +83 -0
  74. sourcebot/runtime/dag/planner/__init__.py +3 -0
  75. sourcebot/runtime/dag/planner/dag_planner.py +26 -0
  76. sourcebot/runtime/dag/planner/execution_scheduler.py +35 -0
  77. sourcebot/runtime/dag/planner/parallelism_optimizer.py +44 -0
  78. sourcebot/runtime/dag/planner/task_decomposer.py +37 -0
  79. sourcebot/runtime/dag/scheduler/__init__.py +3 -0
  80. sourcebot/runtime/dag/scheduler/dag_scheduler.py +319 -0
  81. sourcebot/runtime/dag/scheduler/retry_policy.py +27 -0
  82. sourcebot/runtime/dag/scheduler/run_store.py +58 -0
  83. sourcebot/runtime/dag/scheduler/state_store.py +40 -0
  84. sourcebot/runtime/dag/scheduler/task_graph.py +29 -0
  85. sourcebot/runtime/init_system.py +182 -0
  86. sourcebot/runtime/tool_executor.py +30 -0
  87. sourcebot/security/policy.py +23 -0
  88. sourcebot/session/__init__.py +4 -0
  89. sourcebot/session/jsonl_repository.py +142 -0
  90. sourcebot/session/repository.py +19 -0
  91. sourcebot/session/service.py +44 -0
  92. sourcebot/session/session.py +53 -0
  93. sourcebot/storage/__init__.py +3 -0
  94. sourcebot/storage/rules_loader.py +72 -0
  95. sourcebot/storage/skill_storage.py +51 -0
  96. sourcebot/tools/__init__.py +7 -0
  97. sourcebot/tools/base.py +182 -0
  98. sourcebot/tools/registry.py +81 -0
  99. sourcebot/tools/rule_detail.py +70 -0
  100. sourcebot/tools/rule_list.py +57 -0
  101. sourcebot/tools/shell.py +93 -0
  102. sourcebot/tools/skill_detail.py +61 -0
  103. sourcebot/tools/skill_list.py +68 -0
  104. sourcebot/utils/__init__.py +2 -0
  105. sourcebot/utils/output.py +79 -0
  106. sourcebot-0.1.0.dist-info/METADATA +318 -0
  107. sourcebot-0.1.0.dist-info/RECORD +110 -0
  108. sourcebot-0.1.0.dist-info/WHEEL +5 -0
  109. sourcebot-0.1.0.dist-info/entry_points.txt +2 -0
  110. sourcebot-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ from sourcebot.session.jsonl_repository import JsonlSessionRepository
2
+ from sourcebot.session.service import SessionService
3
+ from sourcebot.session.session import Session
4
+ __all__ = ["JsonlSessionRepository", "Session"]
@@ -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,3 @@
1
+ from sourcebot.storage.skill_storage import SkillStorage
2
+ from sourcebot.storage.rules_loader import RulesLoader
3
+ __all__ = ["SkillStorage", "RulesLoader"]
@@ -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"]
@@ -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