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.
Files changed (154) hide show
  1. bloom/__init__.py +6 -0
  2. bloom/acp/__init__.py +0 -0
  3. bloom/acp/acp_agent_loop.py +559 -0
  4. bloom/acp/entrypoint.py +81 -0
  5. bloom/acp/tools/__init__.py +0 -0
  6. bloom/acp/tools/base.py +100 -0
  7. bloom/acp/tools/builtins/bash.py +134 -0
  8. bloom/acp/tools/builtins/read_file.py +56 -0
  9. bloom/acp/tools/builtins/search_replace.py +129 -0
  10. bloom/acp/tools/builtins/todo.py +65 -0
  11. bloom/acp/tools/builtins/write_file.py +98 -0
  12. bloom/acp/tools/session_update.py +118 -0
  13. bloom/acp/utils.py +113 -0
  14. bloom/cli/__init__.py +0 -0
  15. bloom/cli/autocompletion/__init__.py +0 -0
  16. bloom/cli/autocompletion/base.py +22 -0
  17. bloom/cli/autocompletion/path_completion.py +177 -0
  18. bloom/cli/autocompletion/slash_command.py +99 -0
  19. bloom/cli/cli.py +190 -0
  20. bloom/cli/clipboard.py +92 -0
  21. bloom/cli/commands.py +103 -0
  22. bloom/cli/entrypoint.py +166 -0
  23. bloom/cli/history_manager.py +91 -0
  24. bloom/cli/terminal_setup.py +323 -0
  25. bloom/cli/textual_ui/__init__.py +0 -0
  26. bloom/cli/textual_ui/ansi_markdown.py +58 -0
  27. bloom/cli/textual_ui/app.py +1328 -0
  28. bloom/cli/textual_ui/app.tcss +982 -0
  29. bloom/cli/textual_ui/external_editor.py +32 -0
  30. bloom/cli/textual_ui/handlers/__init__.py +5 -0
  31. bloom/cli/textual_ui/handlers/event_handler.py +147 -0
  32. bloom/cli/textual_ui/terminal_theme.py +266 -0
  33. bloom/cli/textual_ui/widgets/__init__.py +0 -0
  34. bloom/cli/textual_ui/widgets/approval_app.py +192 -0
  35. bloom/cli/textual_ui/widgets/banner/banner.py +92 -0
  36. bloom/cli/textual_ui/widgets/banner/bee.py +278 -0
  37. bloom/cli/textual_ui/widgets/braille_renderer.py +58 -0
  38. bloom/cli/textual_ui/widgets/chat_input/__init__.py +7 -0
  39. bloom/cli/textual_ui/widgets/chat_input/body.py +214 -0
  40. bloom/cli/textual_ui/widgets/chat_input/completion_manager.py +58 -0
  41. bloom/cli/textual_ui/widgets/chat_input/completion_popup.py +43 -0
  42. bloom/cli/textual_ui/widgets/chat_input/container.py +195 -0
  43. bloom/cli/textual_ui/widgets/chat_input/text_area.py +365 -0
  44. bloom/cli/textual_ui/widgets/compact.py +41 -0
  45. bloom/cli/textual_ui/widgets/config_app.py +204 -0
  46. bloom/cli/textual_ui/widgets/context_progress.py +30 -0
  47. bloom/cli/textual_ui/widgets/load_more.py +43 -0
  48. bloom/cli/textual_ui/widgets/loading.py +201 -0
  49. bloom/cli/textual_ui/widgets/messages.py +277 -0
  50. bloom/cli/textual_ui/widgets/no_markup_static.py +11 -0
  51. bloom/cli/textual_ui/widgets/path_display.py +28 -0
  52. bloom/cli/textual_ui/widgets/question_app.py +496 -0
  53. bloom/cli/textual_ui/widgets/spinner.py +194 -0
  54. bloom/cli/textual_ui/widgets/status_message.py +76 -0
  55. bloom/cli/textual_ui/widgets/tool_widgets.py +371 -0
  56. bloom/cli/textual_ui/widgets/tools.py +201 -0
  57. bloom/cli/textual_ui/windowing/__init__.py +29 -0
  58. bloom/cli/textual_ui/windowing/history.py +105 -0
  59. bloom/cli/textual_ui/windowing/history_windowing.py +71 -0
  60. bloom/cli/textual_ui/windowing/state.py +105 -0
  61. bloom/cli/update_notifier/__init__.py +47 -0
  62. bloom/cli/update_notifier/adapters/filesystem_update_cache_repository.py +59 -0
  63. bloom/cli/update_notifier/adapters/github_update_gateway.py +101 -0
  64. bloom/cli/update_notifier/adapters/pypi_update_gateway.py +107 -0
  65. bloom/cli/update_notifier/ports/update_cache_repository.py +16 -0
  66. bloom/cli/update_notifier/ports/update_gateway.py +53 -0
  67. bloom/cli/update_notifier/update.py +139 -0
  68. bloom/cli/update_notifier/whats_new.py +49 -0
  69. bloom/core/__init__.py +5 -0
  70. bloom/core/agent_loop.py +961 -0
  71. bloom/core/agents/__init__.py +31 -0
  72. bloom/core/agents/manager.py +165 -0
  73. bloom/core/agents/models.py +139 -0
  74. bloom/core/auth/__init__.py +6 -0
  75. bloom/core/auth/crypto.py +137 -0
  76. bloom/core/auth/github.py +178 -0
  77. bloom/core/autocompletion/__init__.py +0 -0
  78. bloom/core/autocompletion/completers.py +257 -0
  79. bloom/core/autocompletion/file_indexer/__init__.py +10 -0
  80. bloom/core/autocompletion/file_indexer/ignore_rules.py +156 -0
  81. bloom/core/autocompletion/file_indexer/indexer.py +179 -0
  82. bloom/core/autocompletion/file_indexer/store.py +169 -0
  83. bloom/core/autocompletion/file_indexer/watcher.py +71 -0
  84. bloom/core/autocompletion/fuzzy.py +189 -0
  85. bloom/core/autocompletion/path_prompt.py +108 -0
  86. bloom/core/autocompletion/path_prompt_adapter.py +149 -0
  87. bloom/core/config.py +588 -0
  88. bloom/core/llm/__init__.py +0 -0
  89. bloom/core/llm/backend/__init__.py +0 -0
  90. bloom/core/llm/backend/factory.py +6 -0
  91. bloom/core/llm/backend/generic.py +447 -0
  92. bloom/core/llm/exceptions.py +195 -0
  93. bloom/core/llm/format.py +183 -0
  94. bloom/core/llm/model_discovery.py +103 -0
  95. bloom/core/llm/types.py +120 -0
  96. bloom/core/middleware.py +220 -0
  97. bloom/core/output_formatters.py +85 -0
  98. bloom/core/paths/__init__.py +0 -0
  99. bloom/core/paths/config_paths.py +66 -0
  100. bloom/core/paths/global_paths.py +40 -0
  101. bloom/core/programmatic.py +51 -0
  102. bloom/core/prompts/__init__.py +31 -0
  103. bloom/core/prompts/cli.md +46 -0
  104. bloom/core/prompts/compact.md +48 -0
  105. bloom/core/prompts/dangerous_directory.md +5 -0
  106. bloom/core/prompts/project_context.md +8 -0
  107. bloom/core/prompts/tests.md +1 -0
  108. bloom/core/session/session_loader.py +157 -0
  109. bloom/core/session/session_logger.py +318 -0
  110. bloom/core/session/session_migration.py +41 -0
  111. bloom/core/skills/__init__.py +7 -0
  112. bloom/core/skills/manager.py +133 -0
  113. bloom/core/skills/models.py +92 -0
  114. bloom/core/skills/parser.py +39 -0
  115. bloom/core/system_prompt.py +470 -0
  116. bloom/core/tools/base.py +336 -0
  117. bloom/core/tools/builtins/ask_user_question.py +134 -0
  118. bloom/core/tools/builtins/bash.py +357 -0
  119. bloom/core/tools/builtins/grep.py +310 -0
  120. bloom/core/tools/builtins/prompts/__init__.py +0 -0
  121. bloom/core/tools/builtins/prompts/ask_user_question.md +84 -0
  122. bloom/core/tools/builtins/prompts/bash.md +73 -0
  123. bloom/core/tools/builtins/prompts/grep.md +4 -0
  124. bloom/core/tools/builtins/prompts/read_file.md +13 -0
  125. bloom/core/tools/builtins/prompts/search_replace.md +43 -0
  126. bloom/core/tools/builtins/prompts/task.md +24 -0
  127. bloom/core/tools/builtins/prompts/todo.md +199 -0
  128. bloom/core/tools/builtins/prompts/write_file.md +42 -0
  129. bloom/core/tools/builtins/read_file.py +222 -0
  130. bloom/core/tools/builtins/search_replace.py +456 -0
  131. bloom/core/tools/builtins/task.py +154 -0
  132. bloom/core/tools/builtins/todo.py +134 -0
  133. bloom/core/tools/builtins/write_file.py +160 -0
  134. bloom/core/tools/manager.py +341 -0
  135. bloom/core/tools/mcp.py +358 -0
  136. bloom/core/tools/ui.py +68 -0
  137. bloom/core/trusted_folders.py +83 -0
  138. bloom/core/types.py +396 -0
  139. bloom/core/utils.py +350 -0
  140. bloom/setup/onboarding/__init__.py +53 -0
  141. bloom/setup/onboarding/base.py +14 -0
  142. bloom/setup/onboarding/onboarding.tcss +230 -0
  143. bloom/setup/onboarding/screens/__init__.py +7 -0
  144. bloom/setup/onboarding/screens/api_key.py +170 -0
  145. bloom/setup/onboarding/screens/theme_selection.py +164 -0
  146. bloom/setup/onboarding/screens/welcome.py +136 -0
  147. bloom/setup/trusted_folders/trust_folder_dialog.py +180 -0
  148. bloom/setup/trusted_folders/trust_folder_dialog.tcss +83 -0
  149. bloom/whats_new.md +7 -0
  150. bloom_cli-0.1.0.dist-info/METADATA +146 -0
  151. bloom_cli-0.1.0.dist-info/RECORD +154 -0
  152. bloom_cli-0.1.0.dist-info/WHEEL +4 -0
  153. bloom_cli-0.1.0.dist-info/entry_points.txt +3 -0
  154. bloom_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
bloom/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ BLOOM_ROOT = Path(__file__).parent
6
+ __version__ = "0.1.0"
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
@@ -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