bloom-cli 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.
- bloom/__init__.py +6 -0
- bloom/acp/__init__.py +0 -0
- bloom/acp/acp_agent_loop.py +559 -0
- bloom/acp/entrypoint.py +81 -0
- bloom/acp/tools/__init__.py +0 -0
- bloom/acp/tools/base.py +100 -0
- bloom/acp/tools/builtins/bash.py +134 -0
- bloom/acp/tools/builtins/read_file.py +56 -0
- bloom/acp/tools/builtins/search_replace.py +129 -0
- bloom/acp/tools/builtins/todo.py +65 -0
- bloom/acp/tools/builtins/write_file.py +98 -0
- bloom/acp/tools/session_update.py +118 -0
- bloom/acp/utils.py +113 -0
- bloom/cli/__init__.py +0 -0
- bloom/cli/autocompletion/__init__.py +0 -0
- bloom/cli/autocompletion/base.py +22 -0
- bloom/cli/autocompletion/path_completion.py +177 -0
- bloom/cli/autocompletion/slash_command.py +99 -0
- bloom/cli/cli.py +190 -0
- bloom/cli/clipboard.py +92 -0
- bloom/cli/commands.py +103 -0
- bloom/cli/entrypoint.py +166 -0
- bloom/cli/history_manager.py +91 -0
- bloom/cli/terminal_setup.py +323 -0
- bloom/cli/textual_ui/__init__.py +0 -0
- bloom/cli/textual_ui/ansi_markdown.py +58 -0
- bloom/cli/textual_ui/app.py +1328 -0
- bloom/cli/textual_ui/app.tcss +982 -0
- bloom/cli/textual_ui/external_editor.py +32 -0
- bloom/cli/textual_ui/handlers/__init__.py +5 -0
- bloom/cli/textual_ui/handlers/event_handler.py +147 -0
- bloom/cli/textual_ui/terminal_theme.py +266 -0
- bloom/cli/textual_ui/widgets/__init__.py +0 -0
- bloom/cli/textual_ui/widgets/approval_app.py +192 -0
- bloom/cli/textual_ui/widgets/banner/banner.py +92 -0
- bloom/cli/textual_ui/widgets/banner/bee.py +278 -0
- bloom/cli/textual_ui/widgets/braille_renderer.py +58 -0
- bloom/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
- bloom/cli/textual_ui/widgets/chat_input/body.py +214 -0
- bloom/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
- bloom/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
- bloom/cli/textual_ui/widgets/chat_input/container.py +195 -0
- bloom/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
- bloom/cli/textual_ui/widgets/compact.py +41 -0
- bloom/cli/textual_ui/widgets/config_app.py +204 -0
- bloom/cli/textual_ui/widgets/context_progress.py +30 -0
- bloom/cli/textual_ui/widgets/load_more.py +43 -0
- bloom/cli/textual_ui/widgets/loading.py +201 -0
- bloom/cli/textual_ui/widgets/messages.py +277 -0
- bloom/cli/textual_ui/widgets/no_markup_static.py +11 -0
- bloom/cli/textual_ui/widgets/path_display.py +28 -0
- bloom/cli/textual_ui/widgets/question_app.py +496 -0
- bloom/cli/textual_ui/widgets/spinner.py +194 -0
- bloom/cli/textual_ui/widgets/status_message.py +76 -0
- bloom/cli/textual_ui/widgets/tool_widgets.py +371 -0
- bloom/cli/textual_ui/widgets/tools.py +201 -0
- bloom/cli/textual_ui/windowing/__init__.py +29 -0
- bloom/cli/textual_ui/windowing/history.py +105 -0
- bloom/cli/textual_ui/windowing/history_windowing.py +71 -0
- bloom/cli/textual_ui/windowing/state.py +105 -0
- bloom/cli/update_notifier/__init__.py +47 -0
- bloom/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
- bloom/cli/update_notifier/adapters/github_update_gateway.py +101 -0
- bloom/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
- bloom/cli/update_notifier/ports/update_cache_repository.py +16 -0
- bloom/cli/update_notifier/ports/update_gateway.py +53 -0
- bloom/cli/update_notifier/update.py +139 -0
- bloom/cli/update_notifier/whats_new.py +49 -0
- bloom/core/__init__.py +5 -0
- bloom/core/agent_loop.py +961 -0
- bloom/core/agents/__init__.py +31 -0
- bloom/core/agents/manager.py +165 -0
- bloom/core/agents/models.py +139 -0
- bloom/core/auth/__init__.py +6 -0
- bloom/core/auth/crypto.py +137 -0
- bloom/core/auth/github.py +178 -0
- bloom/core/autocompletion/__init__.py +0 -0
- bloom/core/autocompletion/completers.py +257 -0
- bloom/core/autocompletion/file_indexer/__init__.py +10 -0
- bloom/core/autocompletion/file_indexer/ignore_rules.py +156 -0
- bloom/core/autocompletion/file_indexer/indexer.py +179 -0
- bloom/core/autocompletion/file_indexer/store.py +169 -0
- bloom/core/autocompletion/file_indexer/watcher.py +71 -0
- bloom/core/autocompletion/fuzzy.py +189 -0
- bloom/core/autocompletion/path_prompt.py +108 -0
- bloom/core/autocompletion/path_prompt_adapter.py +149 -0
- bloom/core/config.py +588 -0
- bloom/core/llm/__init__.py +0 -0
- bloom/core/llm/backend/__init__.py +0 -0
- bloom/core/llm/backend/factory.py +6 -0
- bloom/core/llm/backend/generic.py +447 -0
- bloom/core/llm/exceptions.py +195 -0
- bloom/core/llm/format.py +183 -0
- bloom/core/llm/model_discovery.py +103 -0
- bloom/core/llm/types.py +120 -0
- bloom/core/middleware.py +220 -0
- bloom/core/output_formatters.py +85 -0
- bloom/core/paths/__init__.py +0 -0
- bloom/core/paths/config_paths.py +66 -0
- bloom/core/paths/global_paths.py +40 -0
- bloom/core/programmatic.py +51 -0
- bloom/core/prompts/__init__.py +31 -0
- bloom/core/prompts/cli.md +46 -0
- bloom/core/prompts/compact.md +48 -0
- bloom/core/prompts/dangerous_directory.md +5 -0
- bloom/core/prompts/project_context.md +8 -0
- bloom/core/prompts/tests.md +1 -0
- bloom/core/session/session_loader.py +157 -0
- bloom/core/session/session_logger.py +318 -0
- bloom/core/session/session_migration.py +41 -0
- bloom/core/skills/__init__.py +7 -0
- bloom/core/skills/manager.py +133 -0
- bloom/core/skills/models.py +92 -0
- bloom/core/skills/parser.py +39 -0
- bloom/core/system_prompt.py +470 -0
- bloom/core/tools/base.py +336 -0
- bloom/core/tools/builtins/ask_user_question.py +134 -0
- bloom/core/tools/builtins/bash.py +357 -0
- bloom/core/tools/builtins/grep.py +310 -0
- bloom/core/tools/builtins/prompts/__init__.py +0 -0
- bloom/core/tools/builtins/prompts/ask_user_question.md +84 -0
- bloom/core/tools/builtins/prompts/bash.md +73 -0
- bloom/core/tools/builtins/prompts/grep.md +4 -0
- bloom/core/tools/builtins/prompts/read_file.md +13 -0
- bloom/core/tools/builtins/prompts/search_replace.md +43 -0
- bloom/core/tools/builtins/prompts/task.md +24 -0
- bloom/core/tools/builtins/prompts/todo.md +199 -0
- bloom/core/tools/builtins/prompts/write_file.md +42 -0
- bloom/core/tools/builtins/read_file.py +222 -0
- bloom/core/tools/builtins/search_replace.py +456 -0
- bloom/core/tools/builtins/task.py +154 -0
- bloom/core/tools/builtins/todo.py +134 -0
- bloom/core/tools/builtins/write_file.py +160 -0
- bloom/core/tools/manager.py +341 -0
- bloom/core/tools/mcp.py +358 -0
- bloom/core/tools/ui.py +68 -0
- bloom/core/trusted_folders.py +83 -0
- bloom/core/types.py +396 -0
- bloom/core/utils.py +350 -0
- bloom/setup/onboarding/__init__.py +53 -0
- bloom/setup/onboarding/base.py +14 -0
- bloom/setup/onboarding/onboarding.tcss +230 -0
- bloom/setup/onboarding/screens/__init__.py +7 -0
- bloom/setup/onboarding/screens/api_key.py +170 -0
- bloom/setup/onboarding/screens/theme_selection.py +164 -0
- bloom/setup/onboarding/screens/welcome.py +136 -0
- bloom/setup/trusted_folders/trust_folder_dialog.py +180 -0
- bloom/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
- bloom/whats_new.md +7 -0
- bloom_cli-0.1.0.dist-info/METADATA +146 -0
- bloom_cli-0.1.0.dist-info/RECORD +154 -0
- bloom_cli-0.1.0.dist-info/WHEEL +4 -0
- bloom_cli-0.1.0.dist-info/entry_points.txt +3 -0
- bloom_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
bloom/__init__.py
ADDED
bloom/acp/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, cast, override
|
|
9
|
+
|
|
10
|
+
from acp import (
|
|
11
|
+
PROTOCOL_VERSION,
|
|
12
|
+
Agent as AcpAgent,
|
|
13
|
+
Client,
|
|
14
|
+
InitializeResponse,
|
|
15
|
+
LoadSessionResponse,
|
|
16
|
+
NewSessionResponse,
|
|
17
|
+
PromptResponse,
|
|
18
|
+
RequestError,
|
|
19
|
+
SetSessionModelResponse,
|
|
20
|
+
SetSessionModeResponse,
|
|
21
|
+
run_agent,
|
|
22
|
+
)
|
|
23
|
+
from acp.helpers import ContentBlock, SessionUpdate
|
|
24
|
+
from acp.schema import (
|
|
25
|
+
AgentCapabilities,
|
|
26
|
+
AgentMessageChunk,
|
|
27
|
+
AgentThoughtChunk,
|
|
28
|
+
AllowedOutcome,
|
|
29
|
+
AuthenticateResponse,
|
|
30
|
+
AuthMethod,
|
|
31
|
+
ClientCapabilities,
|
|
32
|
+
ContentToolCallContent,
|
|
33
|
+
ForkSessionResponse,
|
|
34
|
+
HttpMcpServer,
|
|
35
|
+
Implementation,
|
|
36
|
+
ListSessionsResponse,
|
|
37
|
+
McpServerStdio,
|
|
38
|
+
ModelInfo,
|
|
39
|
+
PromptCapabilities,
|
|
40
|
+
ResumeSessionResponse,
|
|
41
|
+
SessionModelState,
|
|
42
|
+
SessionModeState,
|
|
43
|
+
SseMcpServer,
|
|
44
|
+
TextContentBlock,
|
|
45
|
+
TextResourceContents,
|
|
46
|
+
ToolCallProgress,
|
|
47
|
+
ToolCallUpdate,
|
|
48
|
+
UserMessageChunk,
|
|
49
|
+
)
|
|
50
|
+
from pydantic import BaseModel, ConfigDict
|
|
51
|
+
|
|
52
|
+
from bloom import BLOOM_ROOT, __version__
|
|
53
|
+
from bloom.acp.tools.base import BaseAcpTool
|
|
54
|
+
from bloom.acp.tools.session_update import (
|
|
55
|
+
tool_call_session_update,
|
|
56
|
+
tool_result_session_update,
|
|
57
|
+
)
|
|
58
|
+
from bloom.acp.utils import (
|
|
59
|
+
TOOL_OPTIONS,
|
|
60
|
+
ToolOption,
|
|
61
|
+
create_compact_end_session_update,
|
|
62
|
+
create_compact_start_session_update,
|
|
63
|
+
get_all_acp_session_modes,
|
|
64
|
+
is_valid_acp_agent,
|
|
65
|
+
)
|
|
66
|
+
from bloom.core.agent_loop import AgentLoop
|
|
67
|
+
from bloom.core.agents.models import BuiltinAgentName
|
|
68
|
+
from bloom.core.autocompletion.path_prompt_adapter import render_path_prompt
|
|
69
|
+
from bloom.core.config import BloomConfig, MissingAPIKeyError, load_dotenv_values
|
|
70
|
+
from bloom.core.tools.base import BaseToolConfig, ToolPermission
|
|
71
|
+
from bloom.core.types import (
|
|
72
|
+
ApprovalResponse,
|
|
73
|
+
AssistantEvent,
|
|
74
|
+
AsyncApprovalCallback,
|
|
75
|
+
CompactEndEvent,
|
|
76
|
+
CompactStartEvent,
|
|
77
|
+
ReasoningEvent,
|
|
78
|
+
ToolCallEvent,
|
|
79
|
+
ToolResultEvent,
|
|
80
|
+
ToolStreamEvent,
|
|
81
|
+
UserMessageEvent,
|
|
82
|
+
)
|
|
83
|
+
from bloom.core.utils import CancellationReason, get_user_cancellation_message
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AcpSessionLoop(BaseModel):
|
|
87
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
88
|
+
id: str
|
|
89
|
+
agent_loop: AgentLoop
|
|
90
|
+
task: asyncio.Task[None] | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BloomAcpAgentLoop(AcpAgent):
|
|
94
|
+
client: Client
|
|
95
|
+
|
|
96
|
+
def __init__(self) -> None:
|
|
97
|
+
self.sessions: dict[str, AcpSessionLoop] = {}
|
|
98
|
+
self.client_capabilities = None
|
|
99
|
+
|
|
100
|
+
@override
|
|
101
|
+
async def initialize(
|
|
102
|
+
self,
|
|
103
|
+
protocol_version: int,
|
|
104
|
+
client_capabilities: ClientCapabilities | None = None,
|
|
105
|
+
client_info: Implementation | None = None,
|
|
106
|
+
**kwargs: Any,
|
|
107
|
+
) -> InitializeResponse:
|
|
108
|
+
self.client_capabilities = client_capabilities
|
|
109
|
+
|
|
110
|
+
# The ACP Agent process can be launched in 3 different ways, depending on installation
|
|
111
|
+
# - dev mode: `uv run bloom-acp`, ran from the project root
|
|
112
|
+
# - uv tool install: `bloom-acp`, similar to dev mode, but uv takes care of path resolution
|
|
113
|
+
# - bundled binary: `./bloom-acp` from binary location
|
|
114
|
+
# The 2 first modes are working similarly, under the hood uv runs `/some/python /my/entrypoint.py``
|
|
115
|
+
# The last mode is quite different as our bundler also includes the python install.
|
|
116
|
+
# So sys.executable is already /path/to/binary/bloom-acp.
|
|
117
|
+
# For this reason, we make a distinction in the way we call the setup command
|
|
118
|
+
command = sys.executable
|
|
119
|
+
if "python" not in Path(command).name:
|
|
120
|
+
# It's the case for bundled binaries, we don't need any other arguments
|
|
121
|
+
args = ["--setup"]
|
|
122
|
+
else:
|
|
123
|
+
script_name = sys.argv[0]
|
|
124
|
+
args = [script_name, "--setup"]
|
|
125
|
+
|
|
126
|
+
supports_terminal_auth = (
|
|
127
|
+
self.client_capabilities
|
|
128
|
+
and self.client_capabilities.field_meta
|
|
129
|
+
and self.client_capabilities.field_meta.get("terminal-auth") is True
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
auth_methods = (
|
|
133
|
+
[
|
|
134
|
+
AuthMethod(
|
|
135
|
+
id="bloom-setup",
|
|
136
|
+
name="Register your API Key",
|
|
137
|
+
description="Register your API Key inside Bloom",
|
|
138
|
+
field_meta={
|
|
139
|
+
"terminal-auth": {
|
|
140
|
+
"command": command,
|
|
141
|
+
"args": args,
|
|
142
|
+
"label": "Bloom Setup",
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
]
|
|
147
|
+
if supports_terminal_auth
|
|
148
|
+
else []
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
response = InitializeResponse(
|
|
152
|
+
agent_capabilities=AgentCapabilities(
|
|
153
|
+
load_session=False,
|
|
154
|
+
prompt_capabilities=PromptCapabilities(
|
|
155
|
+
audio=False, embedded_context=True, image=False
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
protocol_version=PROTOCOL_VERSION,
|
|
159
|
+
agent_info=Implementation(
|
|
160
|
+
name="@ilm-alan/bloom-cli", title="Bloom", version=__version__
|
|
161
|
+
),
|
|
162
|
+
auth_methods=auth_methods,
|
|
163
|
+
)
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
@override
|
|
167
|
+
async def authenticate(
|
|
168
|
+
self, method_id: str, **kwargs: Any
|
|
169
|
+
) -> AuthenticateResponse | None:
|
|
170
|
+
raise NotImplementedError("Not implemented yet")
|
|
171
|
+
|
|
172
|
+
@override
|
|
173
|
+
async def new_session(
|
|
174
|
+
self,
|
|
175
|
+
cwd: str,
|
|
176
|
+
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
|
177
|
+
**kwargs: Any,
|
|
178
|
+
) -> NewSessionResponse:
|
|
179
|
+
load_dotenv_values()
|
|
180
|
+
os.chdir(cwd)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
config = BloomConfig.load(disabled_tools=["ask_user_question"])
|
|
184
|
+
config.tool_paths.extend(self._get_acp_tool_overrides())
|
|
185
|
+
except MissingAPIKeyError as e:
|
|
186
|
+
raise RequestError.auth_required({
|
|
187
|
+
"message": "You must be authenticated before creating a new session"
|
|
188
|
+
}) from e
|
|
189
|
+
|
|
190
|
+
agent_loop = AgentLoop(
|
|
191
|
+
config=config, agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True
|
|
192
|
+
)
|
|
193
|
+
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
|
|
194
|
+
# We should just use agent_loop.session_id everywhere, but it can still change during
|
|
195
|
+
# session lifetime (e.g. agent_loop.compact is called).
|
|
196
|
+
# We should refactor agent_loop.session_id to make it immutable in ACP context.
|
|
197
|
+
session = AcpSessionLoop(id=agent_loop.session_id, agent_loop=agent_loop)
|
|
198
|
+
self.sessions[session.id] = session
|
|
199
|
+
|
|
200
|
+
if not agent_loop.auto_approve:
|
|
201
|
+
agent_loop.set_approval_callback(
|
|
202
|
+
self._create_approval_callback(agent_loop.session_id)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
response = NewSessionResponse(
|
|
206
|
+
session_id=agent_loop.session_id,
|
|
207
|
+
models=SessionModelState(
|
|
208
|
+
current_model_id=agent_loop.config.active_model,
|
|
209
|
+
available_models=[
|
|
210
|
+
ModelInfo(model_id=model.alias, name=model.display_name)
|
|
211
|
+
for model in agent_loop.config.models
|
|
212
|
+
],
|
|
213
|
+
),
|
|
214
|
+
modes=SessionModeState(
|
|
215
|
+
current_mode_id=session.agent_loop.agent_profile.name,
|
|
216
|
+
available_modes=get_all_acp_session_modes(agent_loop.agent_manager),
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
return response
|
|
220
|
+
|
|
221
|
+
def _get_acp_tool_overrides(self) -> list[Path]:
|
|
222
|
+
overrides = ["todo"]
|
|
223
|
+
|
|
224
|
+
if self.client_capabilities:
|
|
225
|
+
if self.client_capabilities.terminal:
|
|
226
|
+
overrides.append("bash")
|
|
227
|
+
if self.client_capabilities.fs:
|
|
228
|
+
fs = self.client_capabilities.fs
|
|
229
|
+
if fs.read_text_file:
|
|
230
|
+
overrides.append("read_file")
|
|
231
|
+
if fs.write_text_file:
|
|
232
|
+
overrides.extend(["write_file", "search_replace"])
|
|
233
|
+
|
|
234
|
+
return [
|
|
235
|
+
BLOOM_ROOT / "acp" / "tools" / "builtins" / f"{override}.py"
|
|
236
|
+
for override in overrides
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
def _create_approval_callback(self, session_id: str) -> AsyncApprovalCallback:
|
|
240
|
+
session = self._get_session(session_id)
|
|
241
|
+
|
|
242
|
+
def _handle_permission_selection(
|
|
243
|
+
option_id: str, tool_name: str
|
|
244
|
+
) -> tuple[ApprovalResponse, str | None]:
|
|
245
|
+
match option_id:
|
|
246
|
+
case ToolOption.ALLOW_ONCE:
|
|
247
|
+
return (ApprovalResponse.YES, None)
|
|
248
|
+
case ToolOption.ALLOW_ALWAYS:
|
|
249
|
+
if tool_name not in session.agent_loop.config.tools:
|
|
250
|
+
session.agent_loop.config.tools[tool_name] = BaseToolConfig()
|
|
251
|
+
session.agent_loop.config.tools[
|
|
252
|
+
tool_name
|
|
253
|
+
].permission = ToolPermission.ALWAYS
|
|
254
|
+
return (ApprovalResponse.YES, None)
|
|
255
|
+
case ToolOption.REJECT_ONCE:
|
|
256
|
+
return (
|
|
257
|
+
ApprovalResponse.NO,
|
|
258
|
+
"User rejected the tool call, provide an alternative plan",
|
|
259
|
+
)
|
|
260
|
+
case _:
|
|
261
|
+
return (ApprovalResponse.NO, f"Unknown option: {option_id}")
|
|
262
|
+
|
|
263
|
+
async def approval_callback(
|
|
264
|
+
tool_name: str, args: BaseModel, tool_call_id: str
|
|
265
|
+
) -> tuple[ApprovalResponse, str | None]:
|
|
266
|
+
# Create the tool call update
|
|
267
|
+
tool_call = ToolCallUpdate(tool_call_id=tool_call_id)
|
|
268
|
+
|
|
269
|
+
response = await self.client.request_permission(
|
|
270
|
+
session_id=session_id, tool_call=tool_call, options=TOOL_OPTIONS
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Parse the response using isinstance for proper type narrowing
|
|
274
|
+
if response.outcome.outcome == "selected":
|
|
275
|
+
outcome = cast(AllowedOutcome, response.outcome)
|
|
276
|
+
return _handle_permission_selection(outcome.option_id, tool_name)
|
|
277
|
+
else:
|
|
278
|
+
return (
|
|
279
|
+
ApprovalResponse.NO,
|
|
280
|
+
str(
|
|
281
|
+
get_user_cancellation_message(
|
|
282
|
+
CancellationReason.OPERATION_CANCELLED
|
|
283
|
+
)
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return approval_callback
|
|
288
|
+
|
|
289
|
+
def _get_session(self, session_id: str) -> AcpSessionLoop:
|
|
290
|
+
if session_id not in self.sessions:
|
|
291
|
+
raise RequestError.invalid_params({"session": "Not found"})
|
|
292
|
+
return self.sessions[session_id]
|
|
293
|
+
|
|
294
|
+
@override
|
|
295
|
+
async def load_session(
|
|
296
|
+
self,
|
|
297
|
+
cwd: str,
|
|
298
|
+
session_id: str,
|
|
299
|
+
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
|
300
|
+
**kwargs: Any,
|
|
301
|
+
) -> LoadSessionResponse | None:
|
|
302
|
+
raise NotImplementedError()
|
|
303
|
+
|
|
304
|
+
@override
|
|
305
|
+
async def set_session_mode(
|
|
306
|
+
self, mode_id: str, session_id: str, **kwargs: Any
|
|
307
|
+
) -> SetSessionModeResponse | None:
|
|
308
|
+
session = self._get_session(session_id)
|
|
309
|
+
|
|
310
|
+
if not is_valid_acp_agent(session.agent_loop.agent_manager, mode_id):
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
await session.agent_loop.switch_agent(mode_id)
|
|
314
|
+
|
|
315
|
+
if session.agent_loop.auto_approve:
|
|
316
|
+
session.agent_loop.approval_callback = None
|
|
317
|
+
else:
|
|
318
|
+
session.agent_loop.set_approval_callback(
|
|
319
|
+
self._create_approval_callback(session.id)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return SetSessionModeResponse()
|
|
323
|
+
|
|
324
|
+
@override
|
|
325
|
+
async def set_session_model(
|
|
326
|
+
self, model_id: str, session_id: str, **kwargs: Any
|
|
327
|
+
) -> SetSessionModelResponse | None:
|
|
328
|
+
session = self._get_session(session_id)
|
|
329
|
+
|
|
330
|
+
model_aliases = [model.alias for model in session.agent_loop.config.models]
|
|
331
|
+
if model_id not in model_aliases:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
BloomConfig.save_updates({"active_model": model_id})
|
|
335
|
+
|
|
336
|
+
new_config = BloomConfig.load(
|
|
337
|
+
tool_paths=session.agent_loop.config.tool_paths,
|
|
338
|
+
disabled_tools=["ask_user_question"],
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
await session.agent_loop.reload_with_initial_messages(base_config=new_config)
|
|
342
|
+
|
|
343
|
+
return SetSessionModelResponse()
|
|
344
|
+
|
|
345
|
+
@override
|
|
346
|
+
async def list_sessions(
|
|
347
|
+
self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any
|
|
348
|
+
) -> ListSessionsResponse:
|
|
349
|
+
raise NotImplementedError()
|
|
350
|
+
|
|
351
|
+
@override
|
|
352
|
+
async def prompt(
|
|
353
|
+
self, prompt: list[ContentBlock], session_id: str, **kwargs: Any
|
|
354
|
+
) -> PromptResponse:
|
|
355
|
+
session = self._get_session(session_id)
|
|
356
|
+
|
|
357
|
+
if session.task is not None:
|
|
358
|
+
raise RuntimeError(
|
|
359
|
+
"Concurrent prompts are not supported yet, wait for agent loop to finish"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
text_prompt = self._build_text_prompt(prompt)
|
|
363
|
+
|
|
364
|
+
temp_user_message_id: str | None = kwargs.get("messageId")
|
|
365
|
+
|
|
366
|
+
async def agent_loop_task() -> None:
|
|
367
|
+
async for update in self._run_agent_loop(
|
|
368
|
+
session, text_prompt, temp_user_message_id
|
|
369
|
+
):
|
|
370
|
+
await self.client.session_update(session_id=session.id, update=update)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
session.task = asyncio.create_task(agent_loop_task())
|
|
374
|
+
await session.task
|
|
375
|
+
|
|
376
|
+
except asyncio.CancelledError:
|
|
377
|
+
return PromptResponse(stop_reason="cancelled")
|
|
378
|
+
|
|
379
|
+
except Exception as e:
|
|
380
|
+
await self.client.session_update(
|
|
381
|
+
session_id=session_id,
|
|
382
|
+
update=AgentMessageChunk(
|
|
383
|
+
session_update="agent_message_chunk",
|
|
384
|
+
content=TextContentBlock(type="text", text=f"Error: {e!s}"),
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return PromptResponse(stop_reason="refusal")
|
|
389
|
+
|
|
390
|
+
finally:
|
|
391
|
+
session.task = None
|
|
392
|
+
|
|
393
|
+
return PromptResponse(stop_reason="end_turn")
|
|
394
|
+
|
|
395
|
+
def _build_text_prompt(self, acp_prompt: list[ContentBlock]) -> str:
|
|
396
|
+
text_prompt = ""
|
|
397
|
+
for block in acp_prompt:
|
|
398
|
+
separator = "\n\n" if text_prompt else ""
|
|
399
|
+
match block.type:
|
|
400
|
+
# NOTE: ACP supports annotations, but we don't use them here yet.
|
|
401
|
+
case "text":
|
|
402
|
+
text_prompt = f"{text_prompt}{separator}{block.text}"
|
|
403
|
+
case "resource":
|
|
404
|
+
block_content = (
|
|
405
|
+
block.resource.text
|
|
406
|
+
if isinstance(block.resource, TextResourceContents)
|
|
407
|
+
else block.resource.blob
|
|
408
|
+
)
|
|
409
|
+
fields = {"path": block.resource.uri, "content": block_content}
|
|
410
|
+
parts = [
|
|
411
|
+
f"{k}: {v}"
|
|
412
|
+
for k, v in fields.items()
|
|
413
|
+
if v is not None and (v or isinstance(v, (int, float)))
|
|
414
|
+
]
|
|
415
|
+
block_prompt = "\n".join(parts)
|
|
416
|
+
text_prompt = f"{text_prompt}{separator}{block_prompt}"
|
|
417
|
+
case "resource_link":
|
|
418
|
+
# NOTE: we currently keep more information than just the URI
|
|
419
|
+
# making it more detailed than the output of the read_file tool.
|
|
420
|
+
# This is OK, but might be worth testing how it affect performance.
|
|
421
|
+
fields = {
|
|
422
|
+
"uri": block.uri,
|
|
423
|
+
"name": block.name,
|
|
424
|
+
"title": block.title,
|
|
425
|
+
"description": block.description,
|
|
426
|
+
"mime_type": block.mime_type,
|
|
427
|
+
"size": block.size,
|
|
428
|
+
}
|
|
429
|
+
parts = [
|
|
430
|
+
f"{k}: {v}"
|
|
431
|
+
for k, v in fields.items()
|
|
432
|
+
if v is not None and (v or isinstance(v, (int, float)))
|
|
433
|
+
]
|
|
434
|
+
block_prompt = "\n".join(parts)
|
|
435
|
+
text_prompt = f"{text_prompt}{separator}{block_prompt}"
|
|
436
|
+
case _:
|
|
437
|
+
raise ValueError(f"Unsupported content block type: {block.type}")
|
|
438
|
+
return text_prompt
|
|
439
|
+
|
|
440
|
+
async def _run_agent_loop(
|
|
441
|
+
self, session: AcpSessionLoop, prompt: str, user_message_id: str | None = None
|
|
442
|
+
) -> AsyncGenerator[SessionUpdate]:
|
|
443
|
+
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
|
444
|
+
|
|
445
|
+
async for event in session.agent_loop.act(rendered_prompt):
|
|
446
|
+
if isinstance(event, UserMessageEvent):
|
|
447
|
+
yield UserMessageChunk(
|
|
448
|
+
session_update="user_message_chunk",
|
|
449
|
+
content=TextContentBlock(type="text", text=""),
|
|
450
|
+
field_meta={
|
|
451
|
+
"messageId": event.message_id,
|
|
452
|
+
**(
|
|
453
|
+
{"previousMessageId": user_message_id}
|
|
454
|
+
if user_message_id
|
|
455
|
+
else {}
|
|
456
|
+
),
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
elif isinstance(event, AssistantEvent):
|
|
461
|
+
yield AgentMessageChunk(
|
|
462
|
+
session_update="agent_message_chunk",
|
|
463
|
+
content=TextContentBlock(type="text", text=event.content),
|
|
464
|
+
field_meta={"messageId": event.message_id},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
elif isinstance(event, ReasoningEvent):
|
|
468
|
+
yield AgentThoughtChunk(
|
|
469
|
+
session_update="agent_thought_chunk",
|
|
470
|
+
content=TextContentBlock(type="text", text=event.content),
|
|
471
|
+
field_meta={"messageId": event.message_id},
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
elif isinstance(event, ToolCallEvent):
|
|
475
|
+
if issubclass(event.tool_class, BaseAcpTool):
|
|
476
|
+
event.tool_class.update_tool_state(
|
|
477
|
+
tool_manager=session.agent_loop.tool_manager,
|
|
478
|
+
client=self.client,
|
|
479
|
+
session_id=session.id,
|
|
480
|
+
tool_call_id=event.tool_call_id,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
session_update = tool_call_session_update(event)
|
|
484
|
+
if session_update:
|
|
485
|
+
yield session_update
|
|
486
|
+
|
|
487
|
+
elif isinstance(event, ToolResultEvent):
|
|
488
|
+
session_update = tool_result_session_update(event)
|
|
489
|
+
if session_update:
|
|
490
|
+
yield session_update
|
|
491
|
+
|
|
492
|
+
elif isinstance(event, ToolStreamEvent):
|
|
493
|
+
yield ToolCallProgress(
|
|
494
|
+
session_update="tool_call_update",
|
|
495
|
+
tool_call_id=event.tool_call_id,
|
|
496
|
+
content=[
|
|
497
|
+
ContentToolCallContent(
|
|
498
|
+
type="content",
|
|
499
|
+
content=TextContentBlock(type="text", text=event.message),
|
|
500
|
+
)
|
|
501
|
+
],
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
elif isinstance(event, CompactStartEvent):
|
|
505
|
+
yield create_compact_start_session_update(event)
|
|
506
|
+
|
|
507
|
+
elif isinstance(event, CompactEndEvent):
|
|
508
|
+
yield create_compact_end_session_update(event)
|
|
509
|
+
|
|
510
|
+
@override
|
|
511
|
+
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
|
512
|
+
session = self._get_session(session_id)
|
|
513
|
+
if session.task and not session.task.done():
|
|
514
|
+
session.task.cancel()
|
|
515
|
+
session.task = None
|
|
516
|
+
|
|
517
|
+
@override
|
|
518
|
+
async def fork_session(
|
|
519
|
+
self,
|
|
520
|
+
cwd: str,
|
|
521
|
+
session_id: str,
|
|
522
|
+
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
|
523
|
+
**kwargs: Any,
|
|
524
|
+
) -> ForkSessionResponse:
|
|
525
|
+
raise NotImplementedError()
|
|
526
|
+
|
|
527
|
+
@override
|
|
528
|
+
async def resume_session(
|
|
529
|
+
self,
|
|
530
|
+
cwd: str,
|
|
531
|
+
session_id: str,
|
|
532
|
+
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
|
533
|
+
**kwargs: Any,
|
|
534
|
+
) -> ResumeSessionResponse:
|
|
535
|
+
raise NotImplementedError()
|
|
536
|
+
|
|
537
|
+
@override
|
|
538
|
+
async def ext_method(self, method: str, params: dict) -> dict:
|
|
539
|
+
raise NotImplementedError()
|
|
540
|
+
|
|
541
|
+
@override
|
|
542
|
+
async def ext_notification(self, method: str, params: dict) -> None:
|
|
543
|
+
raise NotImplementedError()
|
|
544
|
+
|
|
545
|
+
@override
|
|
546
|
+
def on_connect(self, conn: Client) -> None:
|
|
547
|
+
self.client = conn
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def run_acp_server() -> None:
|
|
551
|
+
try:
|
|
552
|
+
asyncio.run(run_agent(agent=BloomAcpAgentLoop(), use_unstable_protocol=True))
|
|
553
|
+
except KeyboardInterrupt:
|
|
554
|
+
# This is expected when the server is terminated
|
|
555
|
+
pass
|
|
556
|
+
except Exception as e:
|
|
557
|
+
# Log any unexpected errors
|
|
558
|
+
print(f"ACP Agent Server error: {e}", file=sys.stderr)
|
|
559
|
+
raise
|
bloom/acp/entrypoint.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from bloom import __version__
|
|
9
|
+
from bloom.core.config import BloomConfig
|
|
10
|
+
from bloom.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, unlock_config_paths
|
|
11
|
+
from bloom.core.utils import logger
|
|
12
|
+
|
|
13
|
+
# Configure line buffering for subprocess communication
|
|
14
|
+
sys.stdout.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
|
|
15
|
+
sys.stderr.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
|
|
16
|
+
sys.stdin.reconfigure(line_buffering=True) # pyright: ignore[reportAttributeAccessIssue]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Arguments:
|
|
21
|
+
setup: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_arguments() -> Arguments:
|
|
25
|
+
parser = argparse.ArgumentParser(description="Run Bloom in ACP mode")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
return Arguments(setup=args.setup)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def bootstrap_config_files() -> None:
|
|
35
|
+
if not CONFIG_FILE.path.exists():
|
|
36
|
+
try:
|
|
37
|
+
BloomConfig.save_updates(BloomConfig.create_default())
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.error(f"Could not create default config file: {e}")
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
if not HISTORY_FILE.path.exists():
|
|
43
|
+
try:
|
|
44
|
+
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
HISTORY_FILE.path.write_text("Hello Bloom!\n", "utf-8")
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Could not create history file: {e}")
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def handle_debug_mode() -> None:
|
|
52
|
+
if os.environ.get("DEBUG_MODE") != "true":
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
import debugpy
|
|
57
|
+
except ImportError:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
debugpy.listen(("localhost", 5678))
|
|
61
|
+
# uncomment this to wait for the debugger to attach
|
|
62
|
+
# debugpy.wait_for_client()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> None:
|
|
66
|
+
handle_debug_mode()
|
|
67
|
+
unlock_config_paths()
|
|
68
|
+
|
|
69
|
+
from bloom.acp.acp_agent_loop import run_acp_server
|
|
70
|
+
from bloom.setup.onboarding import run_onboarding
|
|
71
|
+
|
|
72
|
+
bootstrap_config_files()
|
|
73
|
+
args = parse_arguments()
|
|
74
|
+
if args.setup:
|
|
75
|
+
run_onboarding()
|
|
76
|
+
sys.exit(0)
|
|
77
|
+
run_acp_server()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
File without changes
|