bub 0.4.2__tar.gz → 0.4.3__tar.gz

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 (91) hide show
  1. {bub-0.4.2 → bub-0.4.3}/PKG-INFO +4 -3
  2. {bub-0.4.2 → bub-0.4.3}/README.md +1 -1
  3. {bub-0.4.2 → bub-0.4.3}/pyproject.toml +1 -0
  4. {bub-0.4.2 → bub-0.4.3}/src/bub/_version.py +2 -2
  5. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/agent.py +3 -9
  6. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/hook_impl.py +42 -3
  7. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/model_runner.py +15 -2
  8. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/settings.py +2 -1
  9. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/__init__.py +17 -7
  10. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/renderer.py +19 -19
  11. bub-0.4.3/src/bub/channels/cli/writers.py +76 -0
  12. {bub-0.4.2 → bub-0.4.3}/src/bub/framework.py +10 -1
  13. {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/specs.py +12 -2
  14. bub-0.4.3/src/bub/store.py +537 -0
  15. {bub-0.4.2/src/bub/builtin → bub-0.4.3/src/bub}/tape.py +163 -10
  16. {bub-0.4.2 → bub-0.4.3}/src/bub/tools.py +1 -1
  17. {bub-0.4.2 → bub-0.4.3}/tests/test_agent_hooks.py +2 -2
  18. {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_agent.py +41 -0
  19. {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_hook_impl.py +67 -5
  20. {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_model_runner.py +19 -4
  21. {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_tools.py +2 -2
  22. {bub-0.4.2 → bub-0.4.3}/tests/test_channels.py +98 -14
  23. bub-0.4.3/tests/test_cli_renderer.py +50 -0
  24. {bub-0.4.2 → bub-0.4.3}/tests/test_file_tape_store_entry_ids.py +2 -2
  25. {bub-0.4.2 → bub-0.4.3}/tests/test_fork_store_merge_back.py +2 -2
  26. {bub-0.4.2 → bub-0.4.3}/tests/test_framework.py +30 -0
  27. bub-0.4.2/tests/test_builtin_tape.py → bub-0.4.3/tests/test_tape.py +21 -3
  28. bub-0.4.2/src/bub/builtin/store.py +0 -284
  29. bub-0.4.2/src/bub/channels/cli/writers.py +0 -42
  30. bub-0.4.2/src/bub/tape.py +0 -366
  31. bub-0.4.2/tests/test_cli_renderer.py +0 -32
  32. {bub-0.4.2 → bub-0.4.3}/.gitignore +0 -0
  33. {bub-0.4.2 → bub-0.4.3}/LICENSE +0 -0
  34. {bub-0.4.2 → bub-0.4.3}/src/bub/__init__.py +0 -0
  35. {bub-0.4.2 → bub-0.4.3}/src/bub/__main__.py +0 -0
  36. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/__init__.py +0 -0
  37. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/auth.py +0 -0
  38. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/cli.py +0 -0
  39. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/codex_provider.py +0 -0
  40. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/context.py +0 -0
  41. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/shell_manager.py +0 -0
  42. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/steering.py +0 -0
  43. {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/tools.py +0 -0
  44. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/__init__.py +0 -0
  45. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/admission.py +0 -0
  46. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/base.py +0 -0
  47. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/ansi_bridge.py +0 -0
  48. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/terminal_output.py +0 -0
  49. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/contracts.py +0 -0
  50. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/handler.py +0 -0
  51. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/manager.py +0 -0
  52. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/message.py +0 -0
  53. {bub-0.4.2 → bub-0.4.3}/src/bub/channels/telegram.py +0 -0
  54. {bub-0.4.2 → bub-0.4.3}/src/bub/configure.py +0 -0
  55. {bub-0.4.2 → bub-0.4.3}/src/bub/envelope.py +0 -0
  56. {bub-0.4.2 → bub-0.4.3}/src/bub/errors.py +0 -0
  57. {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/__init__.py +0 -0
  58. {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/interception.py +0 -0
  59. {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/runtime.py +0 -0
  60. {bub-0.4.2 → bub-0.4.3}/src/bub/inquirer.py +0 -0
  61. {bub-0.4.2 → bub-0.4.3}/src/bub/model_selection.py +0 -0
  62. {bub-0.4.2 → bub-0.4.3}/src/bub/skills.py +0 -0
  63. {bub-0.4.2 → bub-0.4.3}/src/bub/streaming.py +0 -0
  64. {bub-0.4.2 → bub-0.4.3}/src/bub/turn.py +0 -0
  65. {bub-0.4.2 → bub-0.4.3}/src/bub/types.py +0 -0
  66. {bub-0.4.2 → bub-0.4.3}/src/bub/utils.py +0 -0
  67. {bub-0.4.2 → bub-0.4.3}/src/skills/README.md +0 -0
  68. {bub-0.4.2 → bub-0.4.3}/src/skills/gh/SKILL.md +0 -0
  69. {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/SKILL.md +0 -0
  70. {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/license.txt +0 -0
  71. {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  72. {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  73. {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/SKILL.md +0 -0
  74. {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  75. {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/scripts/telegram_rich.py +0 -0
  76. {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/scripts/telegram_send.py +0 -0
  77. {bub-0.4.2 → bub-0.4.3}/tests/conftest.py +0 -0
  78. {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_cli.py +0 -0
  79. {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_codex.py +0 -0
  80. {bub-0.4.2 → bub-0.4.3}/tests/test_cli_help.py +0 -0
  81. {bub-0.4.2 → bub-0.4.3}/tests/test_configure.py +0 -0
  82. {bub-0.4.2 → bub-0.4.3}/tests/test_envelope.py +0 -0
  83. {bub-0.4.2 → bub-0.4.3}/tests/test_hook_runtime.py +0 -0
  84. {bub-0.4.2 → bub-0.4.3}/tests/test_image_message.py +0 -0
  85. {bub-0.4.2 → bub-0.4.3}/tests/test_settings.py +0 -0
  86. {bub-0.4.2 → bub-0.4.3}/tests/test_skills.py +0 -0
  87. {bub-0.4.2 → bub-0.4.3}/tests/test_subagent_tool.py +0 -0
  88. {bub-0.4.2 → bub-0.4.3}/tests/test_tape_search_output.py +0 -0
  89. {bub-0.4.2 → bub-0.4.3}/tests/test_tools.py +0 -0
  90. {bub-0.4.2 → bub-0.4.3}/tests/test_types.py +0 -0
  91. {bub-0.4.2 → bub-0.4.3}/tests/test_utils.py +0 -0
@@ -1,11 +1,12 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: bub
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: A common shape for agents that live alongside people.
5
5
  Project-URL: Homepage, https://bub.build
6
6
  Project-URL: Repository, https://github.com/bubbuild/bub
7
7
  Project-URL: Documentation, https://bub.build
8
8
  Author-email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
9
+ License: Apache-2.0
9
10
  License-File: LICENSE
10
11
  Classifier: Intended Audience :: Developers
11
12
  Classifier: Programming Language :: Python
@@ -154,7 +155,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
154
155
  | `BUB_API_BASE` | — | Custom provider endpoint |
155
156
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
156
157
  | `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
157
- | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
158
+ | `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
158
159
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
159
160
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
160
161
 
@@ -117,7 +117,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
117
117
  | `BUB_API_BASE` | — | Custom provider endpoint |
118
118
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
119
119
  | `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
120
- | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
120
+ | `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
121
121
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
122
122
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
123
123
 
@@ -9,6 +9,7 @@ authors = [
9
9
  ]
10
10
  readme = "README.md"
11
11
  requires-python = ">=3.12,<4.0"
12
+ license = { text = "Apache-2.0" }
12
13
  classifiers = [
13
14
  "Intended Audience :: Developers",
14
15
  "Programming Language :: Python",
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.2'
22
- __version_tuple__ = version_tuple = (0, 4, 2)
21
+ __version__ = version = '0.4.3'
22
+ __version_tuple__ = version_tuple = (0, 4, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -22,12 +22,12 @@ from bub.builtin.model_runner import (
22
22
  is_context_length_error,
23
23
  )
24
24
  from bub.builtin.settings import load_settings
25
- from bub.builtin.tape import Tape
26
25
  from bub.envelope import field_of
27
26
  from bub.framework import BubFramework
28
27
  from bub.skills import discover_skills, render_skills_prompt
28
+ from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore, is_async_tape_store
29
29
  from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
30
- from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, is_async_tape_store
30
+ from bub.tape import Tape
31
31
  from bub.tools import (
32
32
  REGISTRY,
33
33
  Tool,
@@ -37,7 +37,6 @@ from bub.tools import (
37
37
  from bub.turn import TurnState
38
38
  from bub.utils import workspace_from_state
39
39
 
40
- CONTINUE_PROMPT = "Continue the task until all targets are completed."
41
40
  HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
42
41
  MAX_AUTO_HANDOFF_RETRIES = 1
43
42
 
@@ -291,7 +290,7 @@ class Agent:
291
290
  )
292
291
  return
293
292
 
294
- next_prompt = self._continue_prompt(tape)
293
+ next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state)
295
294
  await tape.append_event(
296
295
  "loop.step",
297
296
  {
@@ -398,11 +397,6 @@ class Agent:
398
397
  blocks.append(skills_prompt)
399
398
  return "\n\n".join(blocks)
400
399
 
401
- def _continue_prompt(self, tape: Tape) -> str:
402
- if "context" in tape.context.state:
403
- return f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
404
- return CONTINUE_PROMPT
405
-
406
400
  def _has_steering_messages(self, state: TurnState) -> bool:
407
401
  steering_inbox = self.framework.get_steering_inbox()
408
402
  return bool(steering_inbox and steering_inbox.message_count(state) > 0)
@@ -1,5 +1,6 @@
1
1
  import sys
2
2
  from datetime import datetime
3
+ from difflib import get_close_matches
3
4
  from pathlib import Path
4
5
  from typing import cast
5
6
 
@@ -18,9 +19,11 @@ from bub.channels.message import ChannelMessage, MediaItem
18
19
  from bub.envelope import Envelope, content_of, field_of
19
20
  from bub.framework import BubFramework
20
21
  from bub.hooks import hookimpl
22
+ from bub.hooks.interception import ToolCall, ToolCallDecision
21
23
  from bub.model_selection import ModelChoice, ModelOptions
22
- from bub.streaming import AsyncStreamEvents
23
- from bub.tape import TapeContext, TapeStore
24
+ from bub.store import TapeStore
25
+ from bub.streaming import AsyncStreamEvents, StreamState
26
+ from bub.tape import Tape, TapeContext
24
27
  from bub.turn import TurnState
25
28
 
26
29
  AGENTS_FILE_NAME = "AGENTS.md"
@@ -57,6 +60,7 @@ When responding to a channel message, you MUST:
57
60
  Excessively long context may cause model call failures. In this case, you MAY use tape.info to retrieve the token usage and you SHOULD use tape.handoff tool to shorten the retrieved history.
58
61
  </context_contract>
59
62
  """
63
+ DEFAULT_CONTINUE_PROMPT = "Continue the task until all targets are completed."
60
64
 
61
65
 
62
66
  class BuiltinImpl:
@@ -214,6 +218,13 @@ class BuiltinImpl:
214
218
  model=state.get("model"),
215
219
  )
216
220
 
221
+ @hookimpl
222
+ def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
223
+ del prompt, state
224
+ if "context" in tape.context.state:
225
+ return f"{DEFAULT_CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
226
+ return DEFAULT_CONTINUE_PROMPT
227
+
217
228
  @hookimpl
218
229
  def register_cli_commands(self, app: typer.Typer) -> None:
219
230
  from bub.builtin import cli
@@ -355,7 +366,7 @@ class BuiltinImpl:
355
366
  @hookimpl
356
367
  def provide_tape_store(self) -> TapeStore:
357
368
  import bub
358
- from bub.builtin.store import FileTapeStore
369
+ from bub.store import FileTapeStore
359
370
 
360
371
  return FileTapeStore(directory=bub.home / "tapes")
361
372
 
@@ -378,3 +389,31 @@ class BuiltinImpl:
378
389
  if channel_router is None:
379
390
  return None
380
391
  return await channel_router.admit_channel_message(session_id=session_id, message=message, turn=turn)
392
+
393
+ @hookimpl
394
+ async def before_tool_call(
395
+ self,
396
+ call: ToolCall,
397
+ state: TurnState,
398
+ ) -> ToolCallDecision | None:
399
+ """Recover hallucinated/unknown tool names without interrupting the turn.
400
+
401
+ When the model invokes a tool outside the current model-facing tool set,
402
+ replace it with a guidance ``tool_result`` so the model can re-issue a
403
+ valid call on the next step.
404
+ """
405
+ from bub.tools import REGISTRY, model_tools
406
+
407
+ available_tools = tuple(tool_item.name for tool_item in model_tools(REGISTRY.values()))
408
+ if call.tool in available_tools:
409
+ return None
410
+
411
+ matches = get_close_matches(call.tool, available_tools, n=3, cutoff=0.6)
412
+ if matches:
413
+ suggestions = "\n".join(f"- {name}" for name in matches)
414
+ guidance = f"Tool `{call.tool}` does not exist. Did you mean one of the following?\n{suggestions}"
415
+ elif "skill" in available_tools:
416
+ guidance = f"Tool `{call.tool}` does not exist. Invoke the `skill` tool to list available skills."
417
+ else:
418
+ guidance = f"Tool `{call.tool}` does not exist. No similar tool is available."
419
+ return ToolCallDecision.replace(guidance)
@@ -28,7 +28,6 @@ from pydantic import TypeAdapter, ValidationError
28
28
 
29
29
  from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider
30
30
  from bub.builtin.settings import AgentSettings, ModelCandidate
31
- from bub.builtin.tape import Tape
32
31
  from bub.errors import BubError, ErrorKind
33
32
  from bub.hooks.interception import (
34
33
  AgentHooks,
@@ -37,6 +36,7 @@ from bub.hooks.interception import (
37
36
  LlmCallResult,
38
37
  )
39
38
  from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
39
+ from bub.tape import Tape
40
40
  from bub.tools import Tool, ToolContext, ToolExecutor
41
41
 
42
42
  CONTEXT_LENGTH_PATTERNS = re.compile(
@@ -461,10 +461,23 @@ def tool_invocation_from_native(
461
461
  tool_call: ChatCompletionMessageToolCall,
462
462
  tool_map: dict[str, Tool],
463
463
  ) -> tuple[Tool, dict[str, Any]]:
464
+ """Resolve a model tool call to (runtime tool, arguments).
465
+
466
+ An unknown tool name is not treated as a fatal error: it is surfaced as a
467
+ placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and
468
+ builtin hooks (e.g. ``before_tool_call``) can recover it into a guidance
469
+ ``tool_result`` instead of interrupting the turn. If no hook replaces the
470
+ call, the placeholder raises a clear tool error rather than succeeding with
471
+ an empty result.
472
+ """
464
473
  tool_name, arguments = parse_native_function_call(tool_call)
465
474
  tool_obj = tool_map.get(tool_name)
466
475
  if tool_obj is None:
467
- raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
476
+
477
+ def raise_unknown_tool(**_: Any) -> None:
478
+ raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
479
+
480
+ return Tool(name=tool_name, handler=raise_unknown_tool), arguments
468
481
  return tool_obj, arguments
469
482
 
470
483
 
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import os
4
4
  import pathlib
5
5
  import re
6
+ import sys
6
7
  from dataclasses import dataclass
7
8
  from typing import Any
8
9
 
@@ -56,7 +57,7 @@ class AgentSettings(Settings):
56
57
  fallback_models: list[str] | None = None
57
58
  api_key: str | dict[str, str] | None = None
58
59
  api_base: str | dict[str, str] | None = None
59
- max_steps: int = 50
60
+ max_steps: int = Field(default=sys.maxsize, gt=0)
60
61
  max_tokens: int = DEFAULT_MAX_TOKENS
61
62
  model_timeout_seconds: int | None = None
62
63
  client_args: dict[str, Any] = Field(default_factory=dict)
@@ -25,7 +25,6 @@ from rich.tree import Tree
25
25
 
26
26
  import bub
27
27
  from bub.builtin.agent import Agent
28
- from bub.builtin.tape import TapeInfo
29
28
  from bub.channels.admission import AdmitDecision, TurnSnapshot
30
29
  from bub.channels.base import Interface
31
30
  from bub.channels.cli.ansi_bridge import render_to_ansi
@@ -39,6 +38,7 @@ from bub.channels.contracts import MessageHandler
39
38
  from bub.channels.message import ChannelMessage
40
39
  from bub.envelope import Envelope, field_of
41
40
  from bub.streaming import StreamEvent
41
+ from bub.tape import TapeInfo
42
42
  from bub.tools import REGISTRY, tool_call_reporter
43
43
 
44
44
  _GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment]
@@ -54,10 +54,12 @@ class _StreamPrinter:
54
54
  expand_thinking: bool,
55
55
  presenter: TerminalPresenter,
56
56
  writer: MarkdownWriter | None = None,
57
+ print_end: Callable[[], None] | None = None,
57
58
  invalidate: Callable[[], None] | None = None,
58
59
  ) -> None:
59
60
  self._console = console
60
61
  self._print_head = print_head
62
+ self._print_end = print_end or (lambda: None)
61
63
  self._expand_thinking = expand_thinking
62
64
  self._presenter = presenter
63
65
  self._reasoning_chars = 0
@@ -82,13 +84,10 @@ class _StreamPrinter:
82
84
 
83
85
  async def _record_reasoning(self, reasoning: str) -> None:
84
86
  if not self._expand_thinking:
85
- if self._reasoning_chars == 0:
86
- await self._ensure_head()
87
87
  self._reasoning_chars += len(reasoning)
88
88
  self._invalidate()
89
89
  return
90
90
 
91
- await self._ensure_head()
92
91
  if not self._reasoning_streaming:
93
92
  await self._print(Text("[-] Thinking", style="dim"))
94
93
  self._reasoning_streaming = True
@@ -97,19 +96,21 @@ class _StreamPrinter:
97
96
  async def _print_content(self, content: str) -> bool:
98
97
  if not (content.strip() or self.head_printed or self._reasoning_chars or self._reasoning_streaming):
99
98
  return False
100
- await self._ensure_head()
101
99
  await self._close_reasoning_stream()
102
100
  await self._flush_reasoning()
101
+ if content.strip() or self.head_printed:
102
+ await self._ensure_head()
103
103
  await self._write_text(content)
104
104
  return True
105
105
 
106
106
  async def finish(self) -> None:
107
107
  await self._close_reasoning_stream()
108
- if self._reasoning_chars:
109
- await self._ensure_head()
110
108
  await self._flush_reasoning()
111
109
  if self._writer.has_content():
110
+ await self._ensure_head()
112
111
  await self._flush_text()
112
+ else:
113
+ await self._ensure_panel_end()
113
114
 
114
115
  async def _print_stream_boundary(self) -> None:
115
116
  await self.finish()
@@ -122,6 +123,12 @@ class _StreamPrinter:
122
123
  await self._presenter.write(self._print_head)
123
124
  self.head_printed = True
124
125
 
126
+ async def _ensure_panel_end(self) -> None:
127
+ if not self.head_printed:
128
+ return
129
+ await self._presenter.write(self._print_end)
130
+ self.head_printed = False
131
+
125
132
  async def _close_reasoning_stream(self) -> None:
126
133
  if not self._reasoning_streaming:
127
134
  return
@@ -163,6 +170,8 @@ class _StreamPrinter:
163
170
  def commit() -> None:
164
171
  self._console.print(finished)
165
172
  self._writer.clear()
173
+ self._print_end()
174
+ self.head_printed = False
166
175
 
167
176
  await self._presenter.write(commit)
168
177
  self._ansi_cache = None
@@ -353,6 +362,7 @@ class CliChannel(Interface):
353
362
  printer = _StreamPrinter(
354
363
  console=console,
355
364
  print_head=lambda: self._renderer.print_head(message.kind),
365
+ print_end=lambda: self._renderer.print_end(message.kind),
356
366
  expand_thinking=self._expand_thinking,
357
367
  presenter=self._presenter,
358
368
  invalidate=self._invalidate_prompt,
@@ -10,6 +10,7 @@ from rich.console import Console
10
10
  from rich.panel import Panel
11
11
  from rich.text import Text
12
12
 
13
+ from bub.channels.cli.writers import PanelEnd, PanelHead
13
14
  from bub.channels.message import MessageKind
14
15
 
15
16
  MAX_TOOL_PAYLOAD_CHARS = 4000
@@ -37,20 +38,10 @@ class CliRenderer:
37
38
  return
38
39
  self.console.print(Text(text, style="bright_black"))
39
40
 
40
- def command_output(self, text: str) -> None:
41
- if not text.strip():
42
- return
43
- self.console.print(f"[cyan bold]Command >[/]\n{text}")
44
-
45
- def assistant_output(self, text: str) -> None:
46
- if not text.strip():
47
- return
48
- self.console.print(f"[blue bold]Assistant >[/]\n{text}")
49
-
50
41
  def error(self, text: str) -> None:
51
42
  if not text.strip():
52
43
  return
53
- self.console.print(f"[red bold]Error >[/]\n{text}")
44
+ self.console.print(Panel(text, title="error", border_style="red"))
54
45
 
55
46
  def input_echo(self, prompt: str, text: str, steering: bool = False) -> None:
56
47
  if not text.strip():
@@ -62,8 +53,7 @@ class CliRenderer:
62
53
  self.console.print(Text(_format_tool_call(name, args, kwargs), style="magenta"), new_line_start=True)
63
54
 
64
55
  def tool_call_success(self, *, name: str, result: Any, elapsed_ms: float) -> None:
65
- rendered = _format_tool_payload(result)
66
- self._tool_result(f"completed in {elapsed_ms:.0f} ms", rendered, style="green")
56
+ self._tool_status(f"completed in {elapsed_ms:.0f} ms", style="green")
67
57
 
68
58
  def tool_call_error(self, *, name: str, error: BaseException, elapsed_ms: float) -> None:
69
59
  rendered = _format_tool_payload({"type": error.__class__.__name__, "message": str(error)})
@@ -71,11 +61,19 @@ class CliRenderer:
71
61
 
72
62
  def print_head(self, kind: MessageKind) -> None:
73
63
  if kind == "command":
74
- self.console.print("[cyan bold]Command >[/]", new_line_start=True)
64
+ self.console.print(PanelHead("Command", border_style="cyan"))
75
65
  elif kind == "error":
76
- self.console.print("[red bold]Error >[/]", new_line_start=True)
66
+ self.console.print(PanelHead("Error", border_style="red"))
77
67
  else:
78
- self.console.print("[blue bold]Assistant >[/]", new_line_start=True)
68
+ self.console.print(PanelHead("Assistant", border_style="blue"))
69
+
70
+ def print_end(self, kind: MessageKind) -> None:
71
+ if kind == "command":
72
+ self.console.print(PanelEnd(border_style="cyan"))
73
+ elif kind == "error":
74
+ self.console.print(PanelEnd(border_style="red"))
75
+ else:
76
+ self.console.print(PanelEnd(border_style="blue"))
79
77
 
80
78
  def log(self, message: object) -> None:
81
79
  text = str(message).rstrip()
@@ -83,11 +81,13 @@ class CliRenderer:
83
81
  self.console.print(text, new_line_start=True)
84
82
 
85
83
  def _tool_result(self, label: str, rendered: str, *, style: str) -> None:
86
- lines = rendered.splitlines() or [""]
87
- self.console.print(Text(f" ⎿ {label}", style=style), highlight=False)
88
- for line in lines:
84
+ self._tool_status(label, style=style)
85
+ for line in rendered.splitlines() or [""]:
89
86
  self.console.print(Text(f" {line}", style="bright_black"), highlight=False)
90
87
 
88
+ def _tool_status(self, label: str, *, style: str) -> None:
89
+ self.console.print(Text(f" ⎿ {label}", style=style), highlight=False)
90
+
91
91
 
92
92
  def _format_tool_call(name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
93
93
  params = _format_tool_params(args, kwargs)
@@ -0,0 +1,76 @@
1
+ """Response-scoped Markdown buffering for CLI streaming output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console, ConsoleOptions, RenderResult
6
+ from rich.markdown import Markdown
7
+ from rich.panel import Panel
8
+ from rich.segment import Segment
9
+ from rich.text import Text
10
+
11
+ _MARKDOWN_CODE_THEME = "ansi_dark"
12
+
13
+
14
+ def _markdown(content: str) -> Markdown:
15
+ return Markdown(content, code_theme=_MARKDOWN_CODE_THEME)
16
+
17
+
18
+ class PanelHead:
19
+ """The top border of a Panel, printed before its content is streamed."""
20
+
21
+ def __init__(self, title: str, *, border_style: str, width: int | None = None) -> None:
22
+ self._title = title
23
+ self._border_style = border_style
24
+ self._width = width
25
+
26
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
27
+ width = self._width if self._width is not None else options.max_width
28
+ panel = Panel("", title=self._title, border_style=self._border_style, width=width)
29
+ for line in console.render_lines(panel, options.update(width=width), pad=False)[:1]:
30
+ yield from line
31
+ yield Segment.line()
32
+
33
+
34
+ class PanelEnd:
35
+ """The bottom border of a Panel, printed after its content is streamed."""
36
+
37
+ def __init__(self, *, border_style: str, width: int | None = None) -> None:
38
+ self._border_style = border_style
39
+ self._width = width
40
+
41
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
42
+ width = self._width if self._width is not None else options.max_width
43
+ panel = Panel("", border_style=self._border_style, width=width)
44
+ for line in console.render_lines(panel, options.update(width=width), pad=False)[-1:]:
45
+ yield from line
46
+ yield Segment.line()
47
+
48
+
49
+ class MarkdownWriter:
50
+ """Keep one response segment as a single Markdown document.
51
+
52
+ Newlines are Markdown structure, not terminal commit boundaries. The
53
+ buffer is drained only at an explicit model or tool boundary.
54
+ """
55
+
56
+ def __init__(self) -> None:
57
+ self._buffer = ""
58
+
59
+ def append(self, text: str) -> None:
60
+ self._buffer += text
61
+
62
+ def render_live(self) -> Markdown | Text:
63
+ if not self._buffer.strip():
64
+ return Text("")
65
+ return _markdown(self._buffer)
66
+
67
+ def render_final(self) -> Markdown | None:
68
+ if not self._buffer.strip():
69
+ return None
70
+ return _markdown(self._buffer.rstrip())
71
+
72
+ def clear(self) -> None:
73
+ self._buffer = ""
74
+
75
+ def has_content(self) -> bool:
76
+ return bool(self._buffer.strip())
@@ -22,7 +22,9 @@ from bub.hooks.interception import AgentHooks
22
22
  from bub.hooks.runtime import _SKIP_VALUE, HookRuntime
23
23
  from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs
24
24
  from bub.model_selection import ModelOptions
25
- from bub.tape import AsyncTapeStore, TapeContext, TapeStore
25
+ from bub.store import AsyncTapeStore, TapeStore
26
+ from bub.streaming import StreamState
27
+ from bub.tape import Tape, TapeContext
26
28
  from bub.turn import TurnResult, TurnState
27
29
  from bub.utils import maybe_context_manager
28
30
 
@@ -122,6 +124,13 @@ class BubFramework:
122
124
  prompt = content_of(message)
123
125
  return cast("str | list[dict[str, Any]]", prompt)
124
126
 
127
+ async def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
128
+ """Build the prompt for the next step of an agent loop."""
129
+ next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=prompt, tape=tape, state=state)
130
+ if isinstance(next_prompt, str):
131
+ return next_prompt
132
+ raise TypeError("hook.continue_prompt must return str")
133
+
125
134
  async def build_state(self, message: Envelope, session_id: str) -> TurnState:
126
135
  state = {"_runtime_workspace": str(self.workspace), "_runtime_steering_inbox": self.get_steering_inbox()}
127
136
  for hook_state in reversed(
@@ -19,8 +19,9 @@ from bub.hooks.interception import (
19
19
  ToolCallResult,
20
20
  )
21
21
  from bub.model_selection import ModelOptions
22
- from bub.streaming import AsyncStreamEvents
23
- from bub.tape import AsyncTapeStore, TapeContext, TapeStore
22
+ from bub.store import AsyncTapeStore, TapeStore
23
+ from bub.streaming import AsyncStreamEvents, StreamState
24
+ from bub.tape import Tape, TapeContext
24
25
  from bub.turn import TurnState
25
26
 
26
27
  if TYPE_CHECKING:
@@ -64,6 +65,15 @@ class BubHookSpecs:
64
65
  """
65
66
  raise NotImplementedError
66
67
 
68
+ @hookspec(firstresult=True)
69
+ def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
70
+ """Build the prompt used to continue an agent loop.
71
+
72
+ Implementations may be synchronous or asynchronous. The first
73
+ non-``None`` result in hook priority order is used.
74
+ """
75
+ raise NotImplementedError
76
+
67
77
  @hookspec
68
78
  def load_state(self, message: Envelope, session_id: str) -> TurnState:
69
79
  """Load state snapshot for one session."""