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,78 @@
|
|
|
1
|
+
|
|
2
|
+
# sourcebot/context/context_builder.py
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
from sourcebot.context.identity import get_identity
|
|
6
|
+
from sourcebot.context.skill import SkillLoader, SkillSummary
|
|
7
|
+
from sourcebot.prompt import DECOMPOSER_PROMPT, SUBAGENT_PROMPT
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class ContextBuilder:
|
|
13
|
+
"""Build system prompts, including identity, rules, memories, and skills."""
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
workspace: Path,
|
|
17
|
+
skill_storage,
|
|
18
|
+
rules_loader,
|
|
19
|
+
):
|
|
20
|
+
self.workspace = workspace
|
|
21
|
+
self.rules_loader = rules_loader
|
|
22
|
+
self.skill_loader = SkillLoader(skill_storage)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_chat_prompt(self):
|
|
26
|
+
parts = [get_identity(str(self.workspace))]
|
|
27
|
+
return "\n\n---\n\n".join(parts)
|
|
28
|
+
|
|
29
|
+
# identity + rules + skill
|
|
30
|
+
def build_system_prompt(self, skill_names: Optional[List[str]] = None) -> str:
|
|
31
|
+
"""Assemble the complete system prompt."""
|
|
32
|
+
|
|
33
|
+
parts = [get_identity(str(self.workspace))]
|
|
34
|
+
# Bootstrap rules
|
|
35
|
+
rules = self.build_rulse()
|
|
36
|
+
if rules:
|
|
37
|
+
parts.append(rules)
|
|
38
|
+
|
|
39
|
+
skills_summary = self.build_skills_summary()
|
|
40
|
+
if skills_summary:
|
|
41
|
+
parts.append(skills_summary)
|
|
42
|
+
return "\n\n---\n\n".join(parts)
|
|
43
|
+
|
|
44
|
+
def build_subagent_prompt(self, workspace: Optional[Path] = None) -> str:
|
|
45
|
+
"""Build a focused system prompt for the subagent."""
|
|
46
|
+
from datetime import datetime
|
|
47
|
+
import time as _time
|
|
48
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
|
49
|
+
tz = _time.strftime("%Z") or "UTC"
|
|
50
|
+
|
|
51
|
+
return SUBAGENT_PROMPT.format(
|
|
52
|
+
now = now,
|
|
53
|
+
tz = tz,
|
|
54
|
+
workspace = "/workspace"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_skills_summary(self) -> str:
|
|
59
|
+
all_skills = self.skill_loader.list_skills(filter_unavailable=False)
|
|
60
|
+
skills_summary = SkillSummary.generate(all_skills, pretty=True)
|
|
61
|
+
return f"""# Skills
|
|
62
|
+
The following skills extend your capabilities. To use a skill, get its SKILL.md file using the skill_detail tool.
|
|
63
|
+
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
|
|
64
|
+
{skills_summary}"""
|
|
65
|
+
|
|
66
|
+
def build_rulse(self, skill_names: Optional[List[str]] = None) -> str:
|
|
67
|
+
return self.rules_loader.load_common_rules()
|
|
68
|
+
|
|
69
|
+
def build_decomposer_prompt(self, skill_names: Optional[List[str]] = None) -> str:
|
|
70
|
+
|
|
71
|
+
skills_summary = self.build_skills_summary()
|
|
72
|
+
rules = self.build_rulse()
|
|
73
|
+
|
|
74
|
+
return DECOMPOSER_PROMPT.format(
|
|
75
|
+
skills_summary = skills_summary,
|
|
76
|
+
rules = rules
|
|
77
|
+
)
|
|
78
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# sourcebot/context/identity.py
|
|
2
|
+
from sourcebot.prompt import IDENTITY_PROMPT
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import platform
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
def get_identity(workspace_path: str) -> str:
|
|
8
|
+
"""Return bot identity/system prompt header."""
|
|
9
|
+
now = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%A)")
|
|
10
|
+
tz = time.strftime("%Z") or "UTC"
|
|
11
|
+
system = platform.system()
|
|
12
|
+
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
|
13
|
+
|
|
14
|
+
return IDENTITY_PROMPT.format(
|
|
15
|
+
now = now,
|
|
16
|
+
tz = tz,
|
|
17
|
+
runtime = runtime,
|
|
18
|
+
workspace_path = "/workspace"
|
|
19
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# domain/context/message_builder.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, List, Optional, Dict
|
|
4
|
+
import base64
|
|
5
|
+
import mimetypes
|
|
6
|
+
from sourcebot.context import ContextBuilder
|
|
7
|
+
from sourcebot.llm.core.message import Message, ToolCall, ToolResult
|
|
8
|
+
from sourcebot.llm.core.message_converter import dict_to_message
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class MessageBuilder:
|
|
14
|
+
"""Build messages for LLM including system, user, and tool messages."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, context_builder: ContextBuilder):
|
|
17
|
+
self.context_builder = context_builder
|
|
18
|
+
|
|
19
|
+
def _build_user_content(
|
|
20
|
+
self,
|
|
21
|
+
text: str,
|
|
22
|
+
media: Optional[List[str]] = None
|
|
23
|
+
) -> Message:
|
|
24
|
+
|
|
25
|
+
metadata: Dict[str, Any] = {}
|
|
26
|
+
media_list: List[Dict[str, Any]] = []
|
|
27
|
+
|
|
28
|
+
if media:
|
|
29
|
+
for path_str in media:
|
|
30
|
+
p = Path(path_str)
|
|
31
|
+
mime, _ = mimetypes.guess_type(str(p))
|
|
32
|
+
|
|
33
|
+
if not p.is_file() or not mime or not mime.startswith("image/"):
|
|
34
|
+
logger.warning(f"Skipping invalid media: {path_str}")
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
b64 = base64.b64encode(p.read_bytes()).decode()
|
|
38
|
+
|
|
39
|
+
media_list.append({
|
|
40
|
+
"filename": p.name,
|
|
41
|
+
"mime": mime,
|
|
42
|
+
"base64": b64
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
if media_list:
|
|
46
|
+
metadata["media"] = media_list
|
|
47
|
+
|
|
48
|
+
return Message(
|
|
49
|
+
role = "user",
|
|
50
|
+
content = text,
|
|
51
|
+
metadata = metadata,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def build_chat_messages(
|
|
55
|
+
self,
|
|
56
|
+
current_message: str,
|
|
57
|
+
history: List[Any] = None,
|
|
58
|
+
skill_names: Optional[List[str]] = None,
|
|
59
|
+
media: Optional[List[str]] = None,
|
|
60
|
+
channel: Optional[str] = None,
|
|
61
|
+
conversation_id: Optional[str] = None,
|
|
62
|
+
) -> List[Message]:
|
|
63
|
+
|
|
64
|
+
messages: List[Message] = []
|
|
65
|
+
|
|
66
|
+
chat_prompt = self.context_builder.build_chat_prompt()
|
|
67
|
+
if channel and conversation_id:
|
|
68
|
+
chat_prompt += f"\n\n## Current Session\nChannel: {channel}\nConversation ID: {conversation_id}"
|
|
69
|
+
|
|
70
|
+
messages.append(Message(role="system", content=chat_prompt))
|
|
71
|
+
|
|
72
|
+
if history:
|
|
73
|
+
for msg in history:
|
|
74
|
+
if isinstance(msg, Message):
|
|
75
|
+
messages.append(msg)
|
|
76
|
+
else:
|
|
77
|
+
messages.append(dict_to_message(msg))
|
|
78
|
+
|
|
79
|
+
user_content = self._build_user_content(current_message, media)
|
|
80
|
+
|
|
81
|
+
messages.append(
|
|
82
|
+
Message(
|
|
83
|
+
role = user_content.role,
|
|
84
|
+
content = user_content.content,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return messages
|
|
89
|
+
|
|
90
|
+
# skills + rules
|
|
91
|
+
def build_messages(
|
|
92
|
+
self,
|
|
93
|
+
current_message: str,
|
|
94
|
+
history: List[Dict[str, Any]] = None,
|
|
95
|
+
skill_names: Optional[List[str]] = None,
|
|
96
|
+
media: Optional[List[str]] = None,
|
|
97
|
+
channel: Optional[str] = None,
|
|
98
|
+
conversation_id: Optional[str] = None,
|
|
99
|
+
) -> List[Dict[str, Any]]:
|
|
100
|
+
messages: List[Message] = []
|
|
101
|
+
|
|
102
|
+
system_prompt = self.context_builder.build_system_prompt(skill_names)
|
|
103
|
+
|
|
104
|
+
if channel and conversation_id:
|
|
105
|
+
system_prompt += f"\n\n## Current Session\nChannel: {channel}\nConversation ID: {conversation_id}"
|
|
106
|
+
|
|
107
|
+
messages.append(Message(role="system", content=system_prompt))
|
|
108
|
+
|
|
109
|
+
user_content = self._build_user_content(current_message, media)
|
|
110
|
+
|
|
111
|
+
messages.append(
|
|
112
|
+
Message(
|
|
113
|
+
role = "user",
|
|
114
|
+
content = user_content["content"],
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return messages
|
|
119
|
+
|
|
120
|
+
def add_tool_result(
|
|
121
|
+
self,
|
|
122
|
+
messages: List[Message],
|
|
123
|
+
tool_call_id: str,
|
|
124
|
+
result: str
|
|
125
|
+
) -> List[Message]:
|
|
126
|
+
|
|
127
|
+
messages.append(
|
|
128
|
+
Message(
|
|
129
|
+
role="tool",
|
|
130
|
+
tool_results=[
|
|
131
|
+
ToolResult(
|
|
132
|
+
tool_call_id=tool_call_id,
|
|
133
|
+
content=result
|
|
134
|
+
)
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return messages
|
|
140
|
+
def add_assistant_message(
|
|
141
|
+
self,
|
|
142
|
+
messages,
|
|
143
|
+
content=None,
|
|
144
|
+
tool_calls=None,
|
|
145
|
+
):
|
|
146
|
+
|
|
147
|
+
msg = Message(
|
|
148
|
+
role="assistant",
|
|
149
|
+
content=content,
|
|
150
|
+
tool_calls=tool_calls or [],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
messages.append(msg)
|
|
154
|
+
return messages
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .skill_context import strip_frontmatter
|
|
2
|
+
from .skill_loader import SkillLoader
|
|
3
|
+
from .skill_metadata import SkillMetadataParser
|
|
4
|
+
from .skill_requirements import check_requirements, missing_requirements
|
|
5
|
+
from .skill_summary import SkillSummary
|
|
6
|
+
from .skill import Skill
|
|
7
|
+
__all__ = ["strip_frontmatter", "SkillLoader", "SkillMetadataParser", "check_requirements", "missing_requirements", "generate", "Skill",]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# sourcebot/skill/skill_context.py
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
def strip_frontmatter(content: str) -> str:
|
|
5
|
+
"""Remove YAML frontmatter from markdown content."""
|
|
6
|
+
if content.startswith("---"):
|
|
7
|
+
match = re.match(r"^---\s*\n.*?\n---\s*\n", content, re.DOTALL)
|
|
8
|
+
if match:
|
|
9
|
+
return content[match.end():].strip()
|
|
10
|
+
return content
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# sourcebot/context/skill/skill_loader.py
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from sourcebot.context.skill.skill import Skill
|
|
4
|
+
from sourcebot.context.skill.skill_metadata import SkillMetadataParser
|
|
5
|
+
from sourcebot.context.skill.skill_requirements import check_requirements
|
|
6
|
+
from sourcebot.storage.skill_storage import SkillStorage
|
|
7
|
+
from sourcebot.context.skill.skill_context import strip_frontmatter
|
|
8
|
+
|
|
9
|
+
class SkillLoader:
|
|
10
|
+
"""Load skills as Skill instances."""
|
|
11
|
+
def __init__(self, skill_storage: SkillStorage):
|
|
12
|
+
self.skill_storage = skill_storage
|
|
13
|
+
self._cache: dict[str, Skill] = {}
|
|
14
|
+
|
|
15
|
+
def list_skills(self, filter_unavailable: bool = True) -> List[Skill]:
|
|
16
|
+
skills: List[Skill] = []
|
|
17
|
+
for name, path, source in self.skill_storage.list_skill_dirs(source = "all"):
|
|
18
|
+
content = path.read_text(encoding="utf-8")
|
|
19
|
+
meta = SkillMetadataParser.parse(content)
|
|
20
|
+
skill = Skill(
|
|
21
|
+
name=name,
|
|
22
|
+
description=meta.get("description", name),
|
|
23
|
+
content=content,
|
|
24
|
+
requirements=meta.get("requires", {}),
|
|
25
|
+
always=meta.get("always", False),
|
|
26
|
+
source=source
|
|
27
|
+
)
|
|
28
|
+
if not filter_unavailable or check_requirements(skill):
|
|
29
|
+
skills.append(skill)
|
|
30
|
+
self._cache[name] = skill
|
|
31
|
+
return skills
|
|
32
|
+
|
|
33
|
+
def load_skill(self, name: str) -> Optional[Skill]:
|
|
34
|
+
if name in self._cache:
|
|
35
|
+
return self._cache[name]
|
|
36
|
+
content = self.skill_storage.read_skill(name)
|
|
37
|
+
if not content:
|
|
38
|
+
return None
|
|
39
|
+
meta = SkillMetadataParser.parse(content)
|
|
40
|
+
skill = Skill(
|
|
41
|
+
name=name,
|
|
42
|
+
description=meta.get("description", name),
|
|
43
|
+
content=content,
|
|
44
|
+
requirements=meta.get("requires", {}),
|
|
45
|
+
always=meta.get("always", False),
|
|
46
|
+
source="root"
|
|
47
|
+
)
|
|
48
|
+
self._cache[name] = skill
|
|
49
|
+
return skill
|
|
50
|
+
|
|
51
|
+
def load_skills_for_context(self, skill_names: List[str]) -> str:
|
|
52
|
+
parts = []
|
|
53
|
+
for name in skill_names:
|
|
54
|
+
skill = self.load_skill(name)
|
|
55
|
+
if skill:
|
|
56
|
+
parts.append(f"### Skill: {skill.name}\n\n{strip_frontmatter(skill.content)}")
|
|
57
|
+
return "\n\n---\n\n".join(parts) if parts else ""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# sourcebot/skill/skill_metadata.py
|
|
2
|
+
import re, json
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
class SkillMetadataParser:
|
|
6
|
+
"""Parse YAML/JSON frontmatter from skill markdown files."""
|
|
7
|
+
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def parse(cls, content: str) -> Dict:
|
|
11
|
+
match = cls.FRONTMATTER_RE.match(content)
|
|
12
|
+
if not match:
|
|
13
|
+
return {}
|
|
14
|
+
front = match.group(1)
|
|
15
|
+
metadata = {}
|
|
16
|
+
for line in front.split("\n"):
|
|
17
|
+
if ":" in line:
|
|
18
|
+
k, v = line.split(":", 1)
|
|
19
|
+
metadata[k.strip()] = v.strip().strip('"\'')
|
|
20
|
+
raw_meta = metadata.get("metadata")
|
|
21
|
+
if raw_meta:
|
|
22
|
+
try:
|
|
23
|
+
data = json.loads(raw_meta)
|
|
24
|
+
return data.get("sourcebot", data.get("openclaw", {}))
|
|
25
|
+
except (json.JSONDecodeError, TypeError):
|
|
26
|
+
return {}
|
|
27
|
+
return metadata
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# sourcebot/context/skill/skill_requirements.py
|
|
2
|
+
|
|
3
|
+
import os, shutil
|
|
4
|
+
from sourcebot.context.skill.skill import Skill
|
|
5
|
+
|
|
6
|
+
def check_requirements(skill: Skill) -> bool:
|
|
7
|
+
req = skill.requirements or {}
|
|
8
|
+
for b in req.get("bins", []):
|
|
9
|
+
if not shutil.which(b):
|
|
10
|
+
return False
|
|
11
|
+
for e in req.get("env", []):
|
|
12
|
+
if not os.environ.get(e):
|
|
13
|
+
return False
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
def missing_requirements(skill: Skill) -> str:
|
|
17
|
+
req = skill.requirements or {}
|
|
18
|
+
missing = []
|
|
19
|
+
for b in req.get("bins", []):
|
|
20
|
+
if not shutil.which(b):
|
|
21
|
+
missing.append(f"CLI:{b}")
|
|
22
|
+
for e in req.get("env", []):
|
|
23
|
+
if not os.environ.get(e):
|
|
24
|
+
missing.append(f"ENV:{e}")
|
|
25
|
+
return ", ".join(missing)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# sourcebot/context/skill/skill_summary.py
|
|
2
|
+
|
|
3
|
+
from xml.etree.ElementTree import Element, SubElement, tostring
|
|
4
|
+
from typing import List
|
|
5
|
+
from sourcebot.context.skill.skill import Skill
|
|
6
|
+
from sourcebot.context.skill.skill_requirements import check_requirements, missing_requirements
|
|
7
|
+
|
|
8
|
+
class SkillSummary:
|
|
9
|
+
"""Generate XML summary of a list of skills."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def generate(skills: List[Skill], pretty: bool = False) -> str:
|
|
13
|
+
root = Element("skills")
|
|
14
|
+
for skill in skills:
|
|
15
|
+
skill_elem = SubElement(root, "skill")
|
|
16
|
+
available = check_requirements(skill)
|
|
17
|
+
skill_elem.set("available", str(available).lower())
|
|
18
|
+
SubElement(skill_elem, "name").text = skill.name
|
|
19
|
+
SubElement(skill_elem, "description").text = skill.description
|
|
20
|
+
SubElement(skill_elem, "location").text = skill.source
|
|
21
|
+
if not available:
|
|
22
|
+
reqs = missing_requirements(skill)
|
|
23
|
+
if reqs:
|
|
24
|
+
SubElement(skill_elem, "requires").text = reqs
|
|
25
|
+
xml_bytes = tostring(root, encoding="unicode")
|
|
26
|
+
if pretty:
|
|
27
|
+
import xml.dom.minidom
|
|
28
|
+
dom = xml.dom.minidom.parseString(xml_bytes)
|
|
29
|
+
return dom.toprettyxml(indent=" ")
|
|
30
|
+
|
|
31
|
+
return xml_bytes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# sourcebot/conversation/service.py
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Callable, Awaitable
|
|
5
|
+
import logging
|
|
6
|
+
from sourcebot.session.session import Session
|
|
7
|
+
from sourcebot.session.service import SessionService
|
|
8
|
+
from sourcebot.memory.service import MemoryService
|
|
9
|
+
from sourcebot.runtime.agent import Agent
|
|
10
|
+
from sourcebot.bus import InboundMessage, OutboundMessage
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class ConversationService:
|
|
15
|
+
"""
|
|
16
|
+
Orchestrates a full user conversation turn.
|
|
17
|
+
|
|
18
|
+
Responsibilities:
|
|
19
|
+
- Command routing
|
|
20
|
+
- Session load/save
|
|
21
|
+
- Context building
|
|
22
|
+
- Agent execution
|
|
23
|
+
- Memory consolidation scheduling
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
session_service: SessionService,
|
|
29
|
+
memory_service: MemoryService,
|
|
30
|
+
agent: Agent,
|
|
31
|
+
message_builder,
|
|
32
|
+
bus = None,
|
|
33
|
+
memory_window: int = 50,
|
|
34
|
+
):
|
|
35
|
+
self.session_service = session_service
|
|
36
|
+
self.memory_service = memory_service
|
|
37
|
+
self.agent = agent
|
|
38
|
+
self.message_builder = message_builder
|
|
39
|
+
# self.bus = bus
|
|
40
|
+
self.memory_window = memory_window
|
|
41
|
+
|
|
42
|
+
self._consolidating: set[str] = set()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Entry point
|
|
46
|
+
async def handle_message(
|
|
47
|
+
self,
|
|
48
|
+
msg: InboundMessage,
|
|
49
|
+
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
|
50
|
+
) -> OutboundMessage | None:
|
|
51
|
+
|
|
52
|
+
if self._is_command(msg):
|
|
53
|
+
return await self._handle_command(msg)
|
|
54
|
+
|
|
55
|
+
if msg.channel == "system":
|
|
56
|
+
return await self._handle_system_message(msg)
|
|
57
|
+
return await self._handle_conversation(msg, on_progress)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Conversation flow
|
|
61
|
+
async def _handle_conversation(
|
|
62
|
+
self,
|
|
63
|
+
msg: InboundMessage,
|
|
64
|
+
on_progress: Callable[[str], Awaitable[None]] | None,
|
|
65
|
+
) -> OutboundMessage | None:
|
|
66
|
+
# Session service
|
|
67
|
+
session = self.session_service.get_history(msg.session_key)
|
|
68
|
+
await self._maybe_schedule_consolidation(session)
|
|
69
|
+
messages = self.message_builder.build_chat_messages(
|
|
70
|
+
history = session.get_history(max_messages=self.memory_window),
|
|
71
|
+
current_message = msg.content,
|
|
72
|
+
media = msg.metadata if msg.metadata else None,
|
|
73
|
+
channel = msg.channel,
|
|
74
|
+
conversation_id = msg.conversation_id,
|
|
75
|
+
)
|
|
76
|
+
async def _bus_progress(content: str):
|
|
77
|
+
if not self.bus:
|
|
78
|
+
return
|
|
79
|
+
metadata = dict(msg.metadata or {})
|
|
80
|
+
metadata["_progress"] = True
|
|
81
|
+
await self.bus.publish_outbound(
|
|
82
|
+
OutboundMessage(
|
|
83
|
+
channel = msg.channel,
|
|
84
|
+
conversation_id = msg.conversation_id,
|
|
85
|
+
content = content,
|
|
86
|
+
metadata = data,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
final_content, _, tools_used = await self.agent.run(messages)
|
|
90
|
+
|
|
91
|
+
if final_content is None:
|
|
92
|
+
final_content = "I've completed processing but have no response."
|
|
93
|
+
|
|
94
|
+
self.session_service.append_turn(
|
|
95
|
+
session.key,
|
|
96
|
+
user = msg.content,
|
|
97
|
+
assistant = final_content,
|
|
98
|
+
tools_used = tools_used,
|
|
99
|
+
)
|
|
100
|
+
return OutboundMessage(
|
|
101
|
+
channel = msg.channel,
|
|
102
|
+
conversation_id = msg.conversation_id,
|
|
103
|
+
content = final_content,
|
|
104
|
+
metadata = msg.metadata or {},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# System message
|
|
109
|
+
async def _handle_system_message(
|
|
110
|
+
self,
|
|
111
|
+
msg: InboundMessage,
|
|
112
|
+
) -> OutboundMessage:
|
|
113
|
+
|
|
114
|
+
key = msg.session_key
|
|
115
|
+
session = self.session_service.get(key)
|
|
116
|
+
|
|
117
|
+
messages = self.message_builder.build_messages(
|
|
118
|
+
history = session.get_history(max_messages = self.memory_window),
|
|
119
|
+
current_message = msg.content,
|
|
120
|
+
channel = msg.channel,
|
|
121
|
+
conversation_id = msg.conversation_id,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
final_content, _ = await self.agent.run(messages)
|
|
125
|
+
|
|
126
|
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
|
|
127
|
+
session.add_message("assistant", final_content)
|
|
128
|
+
|
|
129
|
+
self.session_service.save(session)
|
|
130
|
+
|
|
131
|
+
return OutboundMessage(
|
|
132
|
+
channel = msg.channel,
|
|
133
|
+
conversation_id = msg.conversation_id,
|
|
134
|
+
content = final_content or "Background task completed.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# commands
|
|
139
|
+
def _is_command(self, msg: InboundMessage) -> bool:
|
|
140
|
+
return msg.content.strip().startswith("/")
|
|
141
|
+
|
|
142
|
+
async def _handle_command(self, msg: InboundMessage) -> OutboundMessage:
|
|
143
|
+
|
|
144
|
+
cmd = msg.content.strip().lower()
|
|
145
|
+
session = self.session_service.get_history(msg.session_key)
|
|
146
|
+
|
|
147
|
+
if cmd == "/new":
|
|
148
|
+
archived = session.messages.copy()
|
|
149
|
+
session.clear()
|
|
150
|
+
self.session_service.save(session)
|
|
151
|
+
await self.memory_service.consolidate_archived(
|
|
152
|
+
session.key,
|
|
153
|
+
archived
|
|
154
|
+
)
|
|
155
|
+
return OutboundMessage(
|
|
156
|
+
channel = msg.channel,
|
|
157
|
+
conversation_id = msg.conversation_id,
|
|
158
|
+
content = "New session started. Memory consolidation in progress.",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if cmd == "/help":
|
|
162
|
+
return OutboundMessage(
|
|
163
|
+
channel = msg.channel,
|
|
164
|
+
conversation_id = msg.conversation_id,
|
|
165
|
+
content = (
|
|
166
|
+
"Available commands:\n"
|
|
167
|
+
"/new — Start a new conversation\n"
|
|
168
|
+
"/help — Show help"
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
return OutboundMessage(
|
|
172
|
+
channel = msg.channel,
|
|
173
|
+
conversation_id = msg.conversation_id,
|
|
174
|
+
content = "Unknown command.",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Memory consolidation
|
|
178
|
+
async def _maybe_schedule_consolidation(self, session: Session):
|
|
179
|
+
if (
|
|
180
|
+
len(session.messages) <= self.memory_window
|
|
181
|
+
or session.key in self._consolidating
|
|
182
|
+
):
|
|
183
|
+
return
|
|
184
|
+
self._consolidating.add(session.key)
|
|
185
|
+
|
|
186
|
+
async def _task():
|
|
187
|
+
try:
|
|
188
|
+
await self.memory_service.maybe_consolidate(session)
|
|
189
|
+
finally:
|
|
190
|
+
self._consolidating.discard(session.key)
|
|
191
|
+
await _task()
|