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,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,3 @@
1
+ from sourcebot.llm.llm_client_factory import LLMClientFactory
2
+
3
+ __all__ = ["LLMClientFactory"]
@@ -0,0 +1,2 @@
1
+ from sourcebot.llm.anthropic.anthropic_llm_client import AnthropicLLMClient
2
+ __all__ = ["AnthropicLLMClient"]
@@ -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,7 @@
1
+ # sourcebot/llm/core/tool.py
2
+
3
+ class Tool:
4
+ def __init__(self, name: str, description: str, parameters: dict):
5
+ self.name = name
6
+ self.description = description
7
+ self.parameters = parameters
@@ -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,2 @@
1
+ from sourcebot.llm.openai.openai_llm_client import OpenAILLMClient
2
+ __all__ = ["OpenAILLMClient"]
@@ -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,3 @@
1
+ from sourcebot.logging.setup import setup_logging
2
+
3
+ __all__ = ["setup_logging"]
@@ -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)