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,113 @@
|
|
|
1
|
+
# sourcebot/docker_sandbox/dockersandbox.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import docker
|
|
4
|
+
import shlex
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
class DockerSandbox:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
image: str = "python:3.11-slim",
|
|
14
|
+
workdir: str = "/workspace", # Working directory within the container
|
|
15
|
+
host_workspace: str | None = None, # Host directory
|
|
16
|
+
):
|
|
17
|
+
self.client = docker.from_env()
|
|
18
|
+
self.image = image
|
|
19
|
+
self.workdir = workdir
|
|
20
|
+
if not host_workspace:
|
|
21
|
+
raise ValueError("host_workspace must be provided")
|
|
22
|
+
self.host_workspace = str(Path(host_workspace).resolve())
|
|
23
|
+
Path(self.host_workspace).mkdir(parents=True, exist_ok=True)
|
|
24
|
+
self.container = None
|
|
25
|
+
|
|
26
|
+
# Secure Start (async wrapper)
|
|
27
|
+
async def start(self):
|
|
28
|
+
loop = asyncio.get_event_loop()
|
|
29
|
+
await loop.run_in_executor(None, self._start_sync)
|
|
30
|
+
|
|
31
|
+
def _start_sync(self):
|
|
32
|
+
logger.debug(f"Starting Docker sandbox with image {self.image}")
|
|
33
|
+
logger.debug(f"Host workspace: {self.host_workspace}")
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
try:
|
|
37
|
+
self.client.images.get(self.image)
|
|
38
|
+
except docker.errors.ImageNotFound:
|
|
39
|
+
logger.info(f"Pulling image {self.image}")
|
|
40
|
+
self.client.images.pull(self.image)
|
|
41
|
+
|
|
42
|
+
self.container = self.client.containers.create(
|
|
43
|
+
self.image,
|
|
44
|
+
command="sleep infinity",
|
|
45
|
+
tty=True,
|
|
46
|
+
volumes={
|
|
47
|
+
self.host_workspace: {
|
|
48
|
+
"bind": self.workdir,
|
|
49
|
+
"mode": "rw",
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
working_dir=self.workdir,
|
|
53
|
+
)
|
|
54
|
+
self.container.start()
|
|
55
|
+
logger.debug(f"Docker sandbox started: {self.container.id}")
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.error(f"Failed to start Docker sandbox: {e}")
|
|
59
|
+
self.container = None
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
async def execute(self, cmd: str, timeout: Optional[int] = 300) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Execute commands in a Docker container
|
|
65
|
+
"""
|
|
66
|
+
if not self.container:
|
|
67
|
+
raise RuntimeError("DockerSandbox not started")
|
|
68
|
+
|
|
69
|
+
logger.debug(f"Executing command in Docker: {cmd}")
|
|
70
|
+
exec_instance = self.client.api.exec_create(
|
|
71
|
+
container = self.container.id,
|
|
72
|
+
cmd = ["bash", "-c", cmd],
|
|
73
|
+
workdir = self.workdir,
|
|
74
|
+
stdout = True,
|
|
75
|
+
stderr = True,
|
|
76
|
+
tty = True,
|
|
77
|
+
)
|
|
78
|
+
output = self.client.api.exec_start(exec_instance['Id'], detach=False, tty=True)
|
|
79
|
+
exit_code = self.client.api.exec_inspect(exec_instance['Id'])['ExitCode']
|
|
80
|
+
|
|
81
|
+
result = output.decode("utf-8").strip()
|
|
82
|
+
if exit_code != 0:
|
|
83
|
+
logger.warning(f"Command failed (exit {exit_code}): {result}")
|
|
84
|
+
else:
|
|
85
|
+
logger.debug(f"Command succeeded: {result}")
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Safe stop
|
|
90
|
+
async def stop(self):
|
|
91
|
+
loop = asyncio.get_event_loop()
|
|
92
|
+
await loop.run_in_executor(None, self._stop_sync)
|
|
93
|
+
|
|
94
|
+
def _stop_sync(self):
|
|
95
|
+
if self.container:
|
|
96
|
+
logger.debug(f"Stopping Docker sandbox {self.container.id}")
|
|
97
|
+
try:
|
|
98
|
+
self.container.kill()
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.warning(f"Error killing container: {e}")
|
|
101
|
+
try:
|
|
102
|
+
self.container.remove(force=True)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning(f"Error removing container: {e}")
|
|
105
|
+
self.container = None
|
|
106
|
+
logger.debug("Docker sandbox stopped")
|
|
107
|
+
|
|
108
|
+
async def __aenter__(self):
|
|
109
|
+
await self.start()
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
113
|
+
await self.stop()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# sourcebot/llm/anthropic/adapter.py
|
|
2
|
+
from sourcebot.llm.core.adapter import BaseAdapter
|
|
3
|
+
from sourcebot.llm.core.response import LLMResponse, ToolCall
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AnthropicAdapter(BaseAdapter):
|
|
7
|
+
|
|
8
|
+
def from_response(self, response) -> LLMResponse:
|
|
9
|
+
text_parts = []
|
|
10
|
+
tool_calls = []
|
|
11
|
+
|
|
12
|
+
for block in response.content:
|
|
13
|
+
if block.type == "text":
|
|
14
|
+
text_parts.append(block.text)
|
|
15
|
+
|
|
16
|
+
elif block.type == "tool_use":
|
|
17
|
+
tool_calls.append(
|
|
18
|
+
ToolCall(
|
|
19
|
+
id=block.id,
|
|
20
|
+
name=block.name,
|
|
21
|
+
arguments=block.input,
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return LLMResponse(
|
|
26
|
+
content="\n".join(text_parts) if text_parts else None,
|
|
27
|
+
tool_calls=tool_calls,
|
|
28
|
+
finish_reason=response.stop_reason,
|
|
29
|
+
raw=response,
|
|
30
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# sourcebot/llm/anthropic/anthropic_llm_client.py
|
|
2
|
+
from sourcebot.config import ConfigManager
|
|
3
|
+
from anthropic import AsyncAnthropic
|
|
4
|
+
from sourcebot.llm.core.client import BaseLLMClient
|
|
5
|
+
from .adapter import AnthropicAdapter
|
|
6
|
+
from sourcebot.llm.anthropic.converter import to_anthropic_messages, to_anthropic_tools
|
|
7
|
+
from sourcebot.llm.core.tool_converter import normalize_tools
|
|
8
|
+
|
|
9
|
+
class AnthropicLLMClient(BaseLLMClient):
|
|
10
|
+
|
|
11
|
+
def __init__(self, config_manager, provider_name: str = "anthropic", model_name: str = "claude-3-opus-20240229"):
|
|
12
|
+
provider_config = config_manager.get_provider_config(provider_name)
|
|
13
|
+
self.client = AsyncAnthropic(
|
|
14
|
+
api_key = provider_config.api_key,
|
|
15
|
+
base_url = provider_config.api_base
|
|
16
|
+
)
|
|
17
|
+
self.temperature = provider_config.temperature
|
|
18
|
+
self.model_name = model_name
|
|
19
|
+
self.adapter = AnthropicAdapter()
|
|
20
|
+
|
|
21
|
+
async def complete(self, messages, tools = None):
|
|
22
|
+
tools = normalize_tools(tools)
|
|
23
|
+
system, amsgs = to_anthropic_messages(messages)
|
|
24
|
+
req = {
|
|
25
|
+
"model": self.model_name,
|
|
26
|
+
"messages": amsgs,
|
|
27
|
+
"max_tokens": 4096,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if system:
|
|
31
|
+
req["system"] = system
|
|
32
|
+
|
|
33
|
+
if tools:
|
|
34
|
+
req["tools"] = to_anthropic_tools(tools)
|
|
35
|
+
|
|
36
|
+
resp = await self.client.messages.create(**req)
|
|
37
|
+
|
|
38
|
+
return self.adapter.from_response(resp)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# sourcebot/llm/anthropic/converter.py
|
|
2
|
+
from sourcebot.llm.core.message import Message
|
|
3
|
+
|
|
4
|
+
def to_anthropic_messages(messages: list[Message]):
|
|
5
|
+
system = None
|
|
6
|
+
result = []
|
|
7
|
+
for msg in messages:
|
|
8
|
+
if msg.role == "system":
|
|
9
|
+
system = msg.content
|
|
10
|
+
continue
|
|
11
|
+
if msg.role == "tool":
|
|
12
|
+
# tool_result → user
|
|
13
|
+
for tr in msg.tool_results:
|
|
14
|
+
result.append({
|
|
15
|
+
"role": "user",
|
|
16
|
+
"content": [
|
|
17
|
+
{
|
|
18
|
+
"type": "tool_result",
|
|
19
|
+
"tool_use_id": tr.tool_call_id,
|
|
20
|
+
"content": tr.content,
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
content_blocks = []
|
|
27
|
+
|
|
28
|
+
if msg.content:
|
|
29
|
+
content_blocks.append({
|
|
30
|
+
"type": "text",
|
|
31
|
+
"text": msg.content
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
# tool_call → tool_use
|
|
35
|
+
for tc in msg.tool_calls:
|
|
36
|
+
content_blocks.append({
|
|
37
|
+
"type": "tool_use",
|
|
38
|
+
"id": tc.id,
|
|
39
|
+
"name": tc.name,
|
|
40
|
+
"input": tc.arguments,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
result.append({
|
|
44
|
+
"role": msg.role,
|
|
45
|
+
"content": content_blocks if content_blocks else ""
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return system, result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_anthropic_tools(tools):
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
"name": t.name,
|
|
55
|
+
"description": t.description,
|
|
56
|
+
"input_schema": t.parameters,
|
|
57
|
+
}
|
|
58
|
+
for t in tools
|
|
59
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# sourcebot/llm/core/adapter.py
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from .response import LLMResponse
|
|
5
|
+
from .delta import LLMDelta
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseAdapter(ABC):
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def from_response(self, response) -> LLMResponse:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
def stream_chunk(self, chunk) -> LLMDelta:
|
|
15
|
+
"""Optional: Streaming parsing"""
|
|
16
|
+
return LLMDelta()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# sourcebot/llm/core/client.py
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import AsyncGenerator
|
|
5
|
+
from .response import LLMResponse
|
|
6
|
+
from .delta import LLMDelta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseLLMClient(ABC):
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def complete(self, messages, tools = None) -> LLMResponse:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
async def stream(self, messages, tools = None) -> AsyncGenerator[LLMDelta, None]:
|
|
16
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# sourcebot/llm/core/delta.py
|
|
2
|
+
|
|
3
|
+
class LLMDelta:
|
|
4
|
+
def __init__(
|
|
5
|
+
self,
|
|
6
|
+
content: str = "",
|
|
7
|
+
tool_call_delta: dict = None,
|
|
8
|
+
finish_reason: str = None,
|
|
9
|
+
):
|
|
10
|
+
self.content = content
|
|
11
|
+
self.tool_call_delta = tool_call_delta
|
|
12
|
+
self.finish_reason = finish_reason
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# sourcebot/llm/core/message.py
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolCall:
|
|
7
|
+
def __init__(self, id: str, name: str, arguments: Any):
|
|
8
|
+
self.id = id
|
|
9
|
+
self.name = name
|
|
10
|
+
self.arguments = arguments
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolResult:
|
|
14
|
+
def __init__(self, tool_call_id: str, content: str):
|
|
15
|
+
self.tool_call_id = tool_call_id
|
|
16
|
+
self.content = content
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Message:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
role: str,
|
|
23
|
+
content: Optional[str] = None,
|
|
24
|
+
tool_calls: Optional[List[ToolCall]] = None,
|
|
25
|
+
tool_results: Optional[List[ToolResult]] = None,
|
|
26
|
+
metadata: dict = None,
|
|
27
|
+
):
|
|
28
|
+
self.role = role
|
|
29
|
+
self.content = content
|
|
30
|
+
self.tool_calls = tool_calls or []
|
|
31
|
+
self.tool_results = tool_results or []
|
|
32
|
+
self.metadata = metadata or {}
|
|
33
|
+
|
|
34
|
+
# Convert to dictionary (for storage)
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return {
|
|
37
|
+
"role": self.role,
|
|
38
|
+
"content": self.content,
|
|
39
|
+
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
|
|
40
|
+
"tool_results": [tr.to_dict() for tr in self.tool_results],
|
|
41
|
+
"metadata": self.metadata,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Restore from dict
|
|
45
|
+
@staticmethod
|
|
46
|
+
def from_dict(data: dict) -> "Message":
|
|
47
|
+
return Message(
|
|
48
|
+
role=data.get("role"),
|
|
49
|
+
content=data.get("content"),
|
|
50
|
+
tool_calls=[ToolCall.from_dict(tc) for tc in data.get("tool_calls", [])],
|
|
51
|
+
tool_results=[ToolResult.from_dict(tr) for tr in data.get("tool_results", [])],
|
|
52
|
+
metadata=data.get("metadata", {}),
|
|
53
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# sourcebot/llm/core/message_converter.py
|
|
2
|
+
from sourcebot.llm.core.message import Message, ToolCall, ToolResult
|
|
3
|
+
|
|
4
|
+
def dict_to_message(msg: dict) -> Message:
|
|
5
|
+
role = msg["role"]
|
|
6
|
+
content = msg.get("content")
|
|
7
|
+
|
|
8
|
+
tool_calls = []
|
|
9
|
+
for tc in msg.get("tool_calls", []):
|
|
10
|
+
tool_calls.append(
|
|
11
|
+
ToolCall(
|
|
12
|
+
id=tc["id"],
|
|
13
|
+
name=tc["function"]["name"],
|
|
14
|
+
arguments=tc["function"]["arguments"],
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
tool_results = []
|
|
19
|
+
if role == "tool":
|
|
20
|
+
tool_results.append(
|
|
21
|
+
ToolResult(
|
|
22
|
+
tool_call_id=msg["tool_call_id"],
|
|
23
|
+
content=content,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return Message(
|
|
28
|
+
role=role,
|
|
29
|
+
content=content,
|
|
30
|
+
tool_calls=tool_calls,
|
|
31
|
+
tool_results=tool_results,
|
|
32
|
+
)
|
|
33
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# sourcebot/llm/core/response.py
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolCall:
|
|
7
|
+
def __init__(self, id: str, name: str, arguments: Any):
|
|
8
|
+
self.id = id
|
|
9
|
+
self.name = name
|
|
10
|
+
self.arguments = arguments
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMResponse:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
content: Optional[str],
|
|
17
|
+
tool_calls: Optional[List[ToolCall]] = None,
|
|
18
|
+
finish_reason: Optional[str] = None,
|
|
19
|
+
usage: Optional[Dict[str, int]] = None,
|
|
20
|
+
raw = None,
|
|
21
|
+
):
|
|
22
|
+
self.content = content
|
|
23
|
+
self.tool_calls = tool_calls or []
|
|
24
|
+
self.finish_reason = finish_reason
|
|
25
|
+
self.usage = usage
|
|
26
|
+
self.raw = raw
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def has_tool_calls(self):
|
|
30
|
+
return len(self.tool_calls) > 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# sourcebot/llm/core/tool_converter.py
|
|
2
|
+
|
|
3
|
+
from sourcebot.llm.core.tool import Tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def dict_to_tool(t: dict) -> Tool:
|
|
7
|
+
fn = t["function"]
|
|
8
|
+
|
|
9
|
+
return Tool(
|
|
10
|
+
name=fn["name"],
|
|
11
|
+
description=fn.get("description", ""),
|
|
12
|
+
parameters=fn["parameters"],
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_tools(tools):
|
|
17
|
+
if not tools:
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
normalized = []
|
|
21
|
+
|
|
22
|
+
for t in tools:
|
|
23
|
+
if hasattr(t, "name"):
|
|
24
|
+
normalized.append(t)
|
|
25
|
+
elif isinstance(t, dict):
|
|
26
|
+
normalized.append(dict_to_tool(t))
|
|
27
|
+
else:
|
|
28
|
+
raise TypeError(f"Unsupported tool type: {type(t)}")
|
|
29
|
+
|
|
30
|
+
return normalized
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# sourcebot/llm/core/tool_delta_aggregator.py
|
|
2
|
+
|
|
3
|
+
class ToolCallAggregator:
|
|
4
|
+
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self.calls = {}
|
|
7
|
+
|
|
8
|
+
def apply_delta(self, delta_tool_calls):
|
|
9
|
+
"""
|
|
10
|
+
delta_tool_calls: OpenAI chunk delta.tool_calls
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
for tc in delta_tool_calls:
|
|
14
|
+
idx = tc.index
|
|
15
|
+
|
|
16
|
+
if idx not in self.calls:
|
|
17
|
+
self.calls[idx] = {
|
|
18
|
+
"id": tc.id,
|
|
19
|
+
"name": tc.function.name,
|
|
20
|
+
"arguments": "",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if tc.function.arguments:
|
|
24
|
+
self.calls[idx]["arguments"] += tc.function.arguments
|
|
25
|
+
|
|
26
|
+
def build(self):
|
|
27
|
+
from sourcebot.llm.core.message import ToolCall
|
|
28
|
+
|
|
29
|
+
result = []
|
|
30
|
+
for c in self.calls.values():
|
|
31
|
+
result.append(
|
|
32
|
+
ToolCall(
|
|
33
|
+
id=c["id"],
|
|
34
|
+
name=c["name"],
|
|
35
|
+
arguments=c["arguments"],
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
return result
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# sourcebot/llm/llm_client_factory.py
|
|
2
|
+
from sourcebot.llm.openai import OpenAILLMClient
|
|
3
|
+
from sourcebot.llm.anthropic import AnthropicLLMClient
|
|
4
|
+
|
|
5
|
+
class LLMClientFactory:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def create_client(config_manager, provider_name: str, model_name: str = None):
|
|
8
|
+
"""Create the corresponding client based on the provider."""
|
|
9
|
+
|
|
10
|
+
if provider_name in ["anthropic", "claude"]:
|
|
11
|
+
return AnthropicLLMClient(config_manager, provider_name, model_name)
|
|
12
|
+
else:
|
|
13
|
+
return OpenAILLMClient(config_manager, provider_name, model_name)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# sourcebot/llm/openai/adapter.py
|
|
2
|
+
|
|
3
|
+
from sourcebot.llm.core.adapter import BaseAdapter
|
|
4
|
+
from sourcebot.llm.core.response import LLMResponse, ToolCall
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class OpenAIAdapter(BaseAdapter):
|
|
8
|
+
|
|
9
|
+
def from_response(self, response) -> LLMResponse:
|
|
10
|
+
msg = response.choices[0].message
|
|
11
|
+
|
|
12
|
+
tool_calls = []
|
|
13
|
+
for tc in msg.tool_calls or []:
|
|
14
|
+
tool_calls.append(
|
|
15
|
+
ToolCall(
|
|
16
|
+
id=tc.id,
|
|
17
|
+
name=tc.function.name,
|
|
18
|
+
arguments=tc.function.arguments,
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return LLMResponse(
|
|
23
|
+
content=msg.content,
|
|
24
|
+
tool_calls=tool_calls,
|
|
25
|
+
finish_reason=response.choices[0].finish_reason,
|
|
26
|
+
raw=response,
|
|
27
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# sourcebot/llm/openai/converter.py
|
|
2
|
+
|
|
3
|
+
from sourcebot.llm.core.message import Message, ToolCall, ToolResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def to_openai_messages(messages: list[Message]):
|
|
7
|
+
result = []
|
|
8
|
+
|
|
9
|
+
for msg in messages:
|
|
10
|
+
if msg.role == "tool":
|
|
11
|
+
# tool result
|
|
12
|
+
for tr in msg.tool_results:
|
|
13
|
+
result.append({
|
|
14
|
+
"role": "tool",
|
|
15
|
+
"tool_call_id": tr.tool_call_id,
|
|
16
|
+
"content": tr.content,
|
|
17
|
+
})
|
|
18
|
+
continue
|
|
19
|
+
|
|
20
|
+
m = {
|
|
21
|
+
"role": msg.role,
|
|
22
|
+
"content": msg.content,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if msg.tool_calls:
|
|
26
|
+
m["tool_calls"] = [
|
|
27
|
+
{
|
|
28
|
+
"id": tc.id,
|
|
29
|
+
"type": "function",
|
|
30
|
+
"function": {
|
|
31
|
+
"name": tc.name,
|
|
32
|
+
"arguments": tc.arguments,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
for tc in msg.tool_calls
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
result.append(m)
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
def to_openai_tools(tools):
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
"type": "function",
|
|
46
|
+
"function": {
|
|
47
|
+
"name": t.name,
|
|
48
|
+
"description": t.description,
|
|
49
|
+
"parameters": t.parameters,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
for t in tools
|
|
53
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# sourcebot/llm/openai/openai_llm_client.py
|
|
2
|
+
from openai import AsyncOpenAI
|
|
3
|
+
from sourcebot.llm.core.client import BaseLLMClient
|
|
4
|
+
from .adapter import OpenAIAdapter
|
|
5
|
+
from sourcebot.config import ConfigManager
|
|
6
|
+
from sourcebot.llm.openai.converter import to_openai_messages, to_openai_tools
|
|
7
|
+
from sourcebot.llm.core.tool_converter import normalize_tools
|
|
8
|
+
class OpenAILLMClient(BaseLLMClient):
|
|
9
|
+
|
|
10
|
+
def __init__(self, config_manager, provider_name: str = "dashscope", model_name: str = "qwen3.5-plus"):
|
|
11
|
+
provider_config = config_manager.get_provider_config(provider_name)
|
|
12
|
+
self.client = AsyncOpenAI(
|
|
13
|
+
api_key = provider_config.api_key,
|
|
14
|
+
base_url = provider_config.api_base
|
|
15
|
+
)
|
|
16
|
+
self.temperature = provider_config.temperature
|
|
17
|
+
self.model = model_name
|
|
18
|
+
self.adapter = OpenAIAdapter()
|
|
19
|
+
|
|
20
|
+
async def complete(self, messages, tools = None):
|
|
21
|
+
# messages = normalize_messages(messages)
|
|
22
|
+
oai_msgs = to_openai_messages(messages)
|
|
23
|
+
tools = normalize_tools(tools)
|
|
24
|
+
oai_tools = to_openai_tools(tools) if tools else None
|
|
25
|
+
resp = await self.client.chat.completions.create(
|
|
26
|
+
model = self.model,
|
|
27
|
+
messages = oai_msgs,
|
|
28
|
+
temperature = self.temperature,
|
|
29
|
+
tools = oai_tools,
|
|
30
|
+
)
|
|
31
|
+
return self.adapter.from_response(resp)
|
|
32
|
+
|
|
33
|
+
async def stream(self, messages, tools=None):
|
|
34
|
+
stream = await self.client.chat.completions.create(
|
|
35
|
+
model = self.model,
|
|
36
|
+
messages = messages,
|
|
37
|
+
tools = tools,
|
|
38
|
+
stream = True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async for chunk in stream:
|
|
42
|
+
delta = chunk.choices[0].delta
|
|
43
|
+
|
|
44
|
+
yield {
|
|
45
|
+
"content": delta.content or "",
|
|
46
|
+
"tool_calls": delta.tool_calls,
|
|
47
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from logging.handlers import RotatingFileHandler
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def setup_logging(level=logging.INFO, log_dir="runs/logs"):
|
|
7
|
+
Path(log_dir).mkdir(parents=True, exist_ok=True)
|
|
8
|
+
|
|
9
|
+
log_file = Path(log_dir) / "sourcebot.log"
|
|
10
|
+
|
|
11
|
+
formatter = logging.Formatter(
|
|
12
|
+
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
13
|
+
"%Y-%m-%d %H:%M:%S"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
console = logging.StreamHandler()
|
|
17
|
+
console.setFormatter(formatter)
|
|
18
|
+
|
|
19
|
+
file_handler = RotatingFileHandler(
|
|
20
|
+
log_file,
|
|
21
|
+
maxBytes=10 * 1024 * 1024,
|
|
22
|
+
backupCount=5
|
|
23
|
+
)
|
|
24
|
+
file_handler.setFormatter(formatter)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger()
|
|
27
|
+
logger.setLevel(level)
|
|
28
|
+
|
|
29
|
+
logger.addHandler(console)
|
|
30
|
+
logger.addHandler(file_handler)
|
|
31
|
+
# Third party
|
|
32
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
33
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|