bub 0.4.1__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.1 → bub-0.4.3}/PKG-INFO +5 -3
  2. {bub-0.4.1 → bub-0.4.3}/README.md +2 -1
  3. {bub-0.4.1 → bub-0.4.3}/pyproject.toml +1 -0
  4. {bub-0.4.1 → bub-0.4.3}/src/bub/_version.py +2 -2
  5. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/agent.py +4 -11
  6. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/cli.py +7 -0
  7. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/hook_impl.py +56 -7
  8. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/model_runner.py +35 -13
  9. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/settings.py +5 -3
  10. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/tools.py +17 -27
  11. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/__init__.py +17 -7
  12. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/renderer.py +19 -19
  13. bub-0.4.3/src/bub/channels/cli/writers.py +76 -0
  14. {bub-0.4.1 → bub-0.4.3}/src/bub/framework.py +10 -1
  15. {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/specs.py +12 -2
  16. bub-0.4.3/src/bub/store.py +537 -0
  17. {bub-0.4.1/src/bub/builtin → bub-0.4.3/src/bub}/tape.py +163 -10
  18. {bub-0.4.1 → bub-0.4.3}/src/bub/tools.py +32 -3
  19. {bub-0.4.1 → bub-0.4.3}/tests/test_agent_hooks.py +3 -3
  20. {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_agent.py +76 -0
  21. {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_cli.py +45 -0
  22. {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_hook_impl.py +80 -5
  23. {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_model_runner.py +78 -4
  24. {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_tools.py +42 -45
  25. {bub-0.4.1 → bub-0.4.3}/tests/test_channels.py +98 -14
  26. bub-0.4.3/tests/test_cli_renderer.py +50 -0
  27. {bub-0.4.1 → bub-0.4.3}/tests/test_file_tape_store_entry_ids.py +2 -2
  28. {bub-0.4.1 → bub-0.4.3}/tests/test_fork_store_merge_back.py +2 -2
  29. {bub-0.4.1 → bub-0.4.3}/tests/test_framework.py +30 -0
  30. {bub-0.4.1 → bub-0.4.3}/tests/test_settings.py +8 -1
  31. bub-0.4.1/tests/test_builtin_tape.py → bub-0.4.3/tests/test_tape.py +21 -3
  32. {bub-0.4.1 → bub-0.4.3}/tests/test_tools.py +64 -1
  33. bub-0.4.1/src/bub/builtin/store.py +0 -284
  34. bub-0.4.1/src/bub/channels/cli/writers.py +0 -42
  35. bub-0.4.1/src/bub/tape.py +0 -366
  36. bub-0.4.1/tests/test_cli_renderer.py +0 -32
  37. {bub-0.4.1 → bub-0.4.3}/.gitignore +0 -0
  38. {bub-0.4.1 → bub-0.4.3}/LICENSE +0 -0
  39. {bub-0.4.1 → bub-0.4.3}/src/bub/__init__.py +0 -0
  40. {bub-0.4.1 → bub-0.4.3}/src/bub/__main__.py +0 -0
  41. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/__init__.py +0 -0
  42. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/auth.py +0 -0
  43. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/codex_provider.py +0 -0
  44. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/context.py +0 -0
  45. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/shell_manager.py +0 -0
  46. {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/steering.py +0 -0
  47. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/__init__.py +0 -0
  48. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/admission.py +0 -0
  49. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/base.py +0 -0
  50. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/ansi_bridge.py +0 -0
  51. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/terminal_output.py +0 -0
  52. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/contracts.py +0 -0
  53. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/handler.py +0 -0
  54. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/manager.py +0 -0
  55. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/message.py +0 -0
  56. {bub-0.4.1 → bub-0.4.3}/src/bub/channels/telegram.py +0 -0
  57. {bub-0.4.1 → bub-0.4.3}/src/bub/configure.py +0 -0
  58. {bub-0.4.1 → bub-0.4.3}/src/bub/envelope.py +0 -0
  59. {bub-0.4.1 → bub-0.4.3}/src/bub/errors.py +0 -0
  60. {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/__init__.py +0 -0
  61. {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/interception.py +0 -0
  62. {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/runtime.py +0 -0
  63. {bub-0.4.1 → bub-0.4.3}/src/bub/inquirer.py +0 -0
  64. {bub-0.4.1 → bub-0.4.3}/src/bub/model_selection.py +0 -0
  65. {bub-0.4.1 → bub-0.4.3}/src/bub/skills.py +0 -0
  66. {bub-0.4.1 → bub-0.4.3}/src/bub/streaming.py +0 -0
  67. {bub-0.4.1 → bub-0.4.3}/src/bub/turn.py +0 -0
  68. {bub-0.4.1 → bub-0.4.3}/src/bub/types.py +0 -0
  69. {bub-0.4.1 → bub-0.4.3}/src/bub/utils.py +0 -0
  70. {bub-0.4.1 → bub-0.4.3}/src/skills/README.md +0 -0
  71. {bub-0.4.1 → bub-0.4.3}/src/skills/gh/SKILL.md +0 -0
  72. {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/SKILL.md +0 -0
  73. {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/license.txt +0 -0
  74. {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  75. {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  76. {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/SKILL.md +0 -0
  77. {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  78. {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/scripts/telegram_rich.py +0 -0
  79. {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/scripts/telegram_send.py +0 -0
  80. {bub-0.4.1 → bub-0.4.3}/tests/conftest.py +0 -0
  81. {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_codex.py +0 -0
  82. {bub-0.4.1 → bub-0.4.3}/tests/test_cli_help.py +0 -0
  83. {bub-0.4.1 → bub-0.4.3}/tests/test_configure.py +0 -0
  84. {bub-0.4.1 → bub-0.4.3}/tests/test_envelope.py +0 -0
  85. {bub-0.4.1 → bub-0.4.3}/tests/test_hook_runtime.py +0 -0
  86. {bub-0.4.1 → bub-0.4.3}/tests/test_image_message.py +0 -0
  87. {bub-0.4.1 → bub-0.4.3}/tests/test_skills.py +0 -0
  88. {bub-0.4.1 → bub-0.4.3}/tests/test_subagent_tool.py +0 -0
  89. {bub-0.4.1 → bub-0.4.3}/tests/test_tape_search_output.py +0 -0
  90. {bub-0.4.1 → bub-0.4.3}/tests/test_types.py +0 -0
  91. {bub-0.4.1 → 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.1
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
@@ -153,7 +154,8 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
153
154
  | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
154
155
  | `BUB_API_BASE` | — | Custom provider endpoint |
155
156
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
156
- | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
157
+ | `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
158
+ | `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
157
159
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
158
160
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
159
161
 
@@ -116,7 +116,8 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
116
116
  | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
117
117
  | `BUB_API_BASE` | — | Custom provider endpoint |
118
118
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
119
- | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
119
+ | `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
120
+ | `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
120
121
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
121
122
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
122
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.1'
22
- __version_tuple__ = version_tuple = (0, 4, 1)
21
+ __version__ = version = '0.4.3'
22
+ __version_tuple__ = version_tuple = (0, 4, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -22,21 +22,21 @@ 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,
34
34
  ToolContext,
35
+ model_tools,
35
36
  )
36
37
  from bub.turn import TurnState
37
38
  from bub.utils import workspace_from_state
38
39
 
39
- CONTINUE_PROMPT = "Continue the task until all targets are completed."
40
40
  HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
41
41
  MAX_AUTO_HANDOFF_RETRIES = 1
42
42
 
@@ -290,7 +290,7 @@ class Agent:
290
290
  )
291
291
  return
292
292
 
293
- next_prompt = self._continue_prompt(tape)
293
+ next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state)
294
294
  await tape.append_event(
295
295
  "loop.step",
296
296
  {
@@ -352,8 +352,6 @@ class Agent:
352
352
  allowed_skills: set[str] | None,
353
353
  tools: list[Tool],
354
354
  ) -> AsyncStreamEvents:
355
- from bub.builtin.tools import model_tools
356
-
357
355
  system_prompt = self._system_prompt(
358
356
  prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
359
357
  )
@@ -399,11 +397,6 @@ class Agent:
399
397
  blocks.append(skills_prompt)
400
398
  return "\n\n".join(blocks)
401
399
 
402
- def _continue_prompt(self, tape: Tape) -> str:
403
- if "context" in tape.context.state:
404
- return f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
405
- return CONTINUE_PROMPT
406
-
407
400
  def _has_steering_messages(self, state: TurnState) -> bool:
408
401
  steering_inbox = self.framework.get_steering_inbox()
409
402
  return bool(steering_inbox and steering_inbox.message_count(state) > 0)
@@ -87,6 +87,13 @@ def gateway(
87
87
  framework = ctx.ensure_object(BubFramework)
88
88
 
89
89
  manager = ChannelManager(framework, enabled_channels=enable_channels or None)
90
+ if not manager.enabled_channels():
91
+ typer.secho(
92
+ "No channels are enabled. Configure at least one channel with 'bub onboard' or '--enable-channel'.",
93
+ err=True,
94
+ fg="red",
95
+ )
96
+ raise typer.Exit(1)
90
97
  asyncio.run(manager.listen_and_run())
91
98
 
92
99
 
@@ -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:
@@ -90,6 +94,16 @@ class BuiltinImpl:
90
94
  return str(model) if model else None
91
95
  return None
92
96
 
97
+ async def _recover_session_reasoning_effort(self, session_id: str) -> str | None:
98
+ """Recover the latest per-session reasoning effort override."""
99
+ session = self._get_agent().tape.session_tape(session_id, self.framework.workspace)
100
+ entries = list(await session.store.fetch_all(session.query().kinds("event")))
101
+ for entry in reversed(entries):
102
+ if entry.kind == "event" and entry.payload.get("name") == "reasoning_effort_switch":
103
+ reasoning_effort = (entry.payload.get("data") or {}).get("reasoning_effort")
104
+ return str(reasoning_effort) if reasoning_effort else None
105
+ return None
106
+
93
107
  @staticmethod
94
108
  async def _discard_message(_: ChannelMessage) -> None:
95
109
  return
@@ -149,6 +163,8 @@ class BuiltinImpl:
149
163
  # fresh/unknown session never inherits another session's model.
150
164
  if model := await self._recover_session_model(session_id):
151
165
  state["model"] = model
166
+ if reasoning_effort := await self._recover_session_reasoning_effort(session_id):
167
+ state["reasoning_effort"] = reasoning_effort
152
168
  if model := field_of(message, "context", {}).get("model"):
153
169
  state["model"] = model
154
170
  if thread_id := field_of(message, "context", {}).get("thread_id"):
@@ -161,9 +177,8 @@ class BuiltinImpl:
161
177
  lifespan = field_of(message, "lifespan")
162
178
  if lifespan is not None:
163
179
  await lifespan.__aexit__(tp, value, traceback)
164
- # The per-session model override is persisted on the session tape by the
165
- # ``model`` tool itself (a ``model_switch`` event, merged back at end of
166
- # turn), so nothing to write here — this hook only closes the lifespan.
180
+ # Per-session completion overrides are persisted by their tools as tape
181
+ # events, so nothing to write here — this hook only closes the lifespan.
167
182
 
168
183
  @hookimpl
169
184
  async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> str | list[dict]:
@@ -203,6 +218,13 @@ class BuiltinImpl:
203
218
  model=state.get("model"),
204
219
  )
205
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
+
206
228
  @hookimpl
207
229
  def register_cli_commands(self, app: typer.Typer) -> None:
208
230
  from bub.builtin import cli
@@ -248,7 +270,6 @@ class BuiltinImpl:
248
270
  "Channels",
249
271
  choices=available_channels,
250
272
  enabled=default_channels,
251
- validate=lambda values: True if values else "Select at least one channel.",
252
273
  )
253
274
 
254
275
  stream_output = bub_inquirer.ask_confirm("Stream output", default=bool(current_config.get("stream_output")))
@@ -345,7 +366,7 @@ class BuiltinImpl:
345
366
  @hookimpl
346
367
  def provide_tape_store(self) -> TapeStore:
347
368
  import bub
348
- from bub.builtin.store import FileTapeStore
369
+ from bub.store import FileTapeStore
349
370
 
350
371
  return FileTapeStore(directory=bub.home / "tapes")
351
372
 
@@ -368,3 +389,31 @@ class BuiltinImpl:
368
389
  if channel_router is None:
369
390
  return None
370
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(
@@ -81,25 +81,33 @@ class ModelRunner:
81
81
  return AnyLLM.create(candidate.provider, **client_kwargs)
82
82
 
83
83
  async def completion_response(
84
- self, *, model: str, messages: list[dict[str, Any]], tools: list[Tool], max_tokens: int | None = None
84
+ self,
85
+ *,
86
+ model: str,
87
+ messages: list[dict[str, Any]],
88
+ tools: list[Tool],
89
+ max_tokens: int | None = None,
90
+ reasoning_effort: str | None = None,
85
91
  ) -> CompletionResult:
86
- from bub.builtin.tools import completion_tools
87
-
88
- tool_payloads = completion_tools(tools) or None
92
+ tool_payloads = [tool.to_schema() for tool in tools] or None
89
93
  completion_messages: list[dict[str, Any] | ChatCompletionMessage] = list(messages)
90
94
  clients = list(self.iter_llm_clients(model))
91
95
  completion_error: Exception | None = None
92
96
  for index, (candidate, llm) in enumerate(clients):
93
97
  try:
94
98
  streaming = llm.SUPPORTS_COMPLETION_STREAMING
95
- return await llm.acompletion(
96
- model=candidate.model_id,
97
- messages=completion_messages,
98
- tools=tool_payloads,
99
- max_tokens=max_tokens if max_tokens is not None else self.settings.max_tokens,
100
- stream=streaming,
99
+ completion_kwargs = {
100
+ **self.settings.completion_args,
101
101
  **_extra_options(llm, stream=streaming),
102
- )
102
+ "model": candidate.model_id,
103
+ "messages": completion_messages,
104
+ "tools": tool_payloads,
105
+ "max_tokens": max_tokens if max_tokens is not None else self.settings.max_tokens,
106
+ "stream": streaming,
107
+ }
108
+ if reasoning_effort is not None:
109
+ completion_kwargs["reasoning_effort"] = reasoning_effort
110
+ return cast("CompletionResult", await llm.acompletion(**completion_kwargs))
103
111
  except Exception as exc:
104
112
  if completion_error is None:
105
113
  completion_error = exc
@@ -173,6 +181,7 @@ class ModelRunner:
173
181
  messages=list(request.messages),
174
182
  tools=tools,
175
183
  max_tokens=request.max_tokens,
184
+ reasoning_effort=tape.context.state.get("reasoning_effort"),
176
185
  )
177
186
  async for event in self._completion_events(completion, state, output):
178
187
  yield event
@@ -452,10 +461,23 @@ def tool_invocation_from_native(
452
461
  tool_call: ChatCompletionMessageToolCall,
453
462
  tool_map: dict[str, Tool],
454
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
+ """
455
473
  tool_name, arguments = parse_native_function_call(tool_call)
456
474
  tool_obj = tool_map.get(tool_name)
457
475
  if tool_obj is None:
458
- 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
459
481
  return tool_obj, arguments
460
482
 
461
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,10 +57,11 @@ 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)
64
+ completion_args: dict[str, Any] = Field(default_factory=dict)
63
65
  verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
64
66
 
65
67
  @classmethod
@@ -79,9 +81,9 @@ class AgentSettings(Settings):
79
81
  file_secret_settings,
80
82
  )
81
83
 
82
- @field_validator("client_args", mode="before")
84
+ @field_validator("client_args", "completion_args", mode="before")
83
85
  @classmethod
84
- def default_client_args(cls, value: Any) -> Any:
86
+ def default_dict_args(cls, value: Any) -> Any:
85
87
  return {} if value is None else value
86
88
 
87
89
  def model_candidates(self, model: str) -> list[ModelCandidate]:
@@ -4,11 +4,9 @@ import asyncio
4
4
  import json
5
5
  import uuid
6
6
  from collections.abc import Iterable
7
- from dataclasses import replace
8
7
  from pathlib import Path
9
8
  from typing import TYPE_CHECKING, cast
10
9
 
11
- from openai.types.chat import ChatCompletionToolParam
12
10
  from pydantic import BaseModel, Field
13
11
 
14
12
  from bub.builtin.shell_manager import shell_manager
@@ -72,11 +70,6 @@ def resolve_tool_names(names: Iterable[str] | None = None, *, exclude: Iterable[
72
70
  return resolved - excluded
73
71
 
74
72
 
75
- def model_tools(tools: Iterable[Tool]) -> list[Tool]:
76
- """Convert runtime tool names into model-safe aliases."""
77
- return [replace(tool_item, name=_to_model_name(tool_item.name)) for tool_item in tools]
78
-
79
-
80
73
  def _tool_signature(tool_item: Tool) -> str:
81
74
  properties = tool_item.parameters.get("properties", {})
82
75
  if not isinstance(properties, dict) or not properties:
@@ -90,10 +83,11 @@ def _tool_signature(tool_item: Tool) -> str:
90
83
 
91
84
  def render_tools_prompt(tools: Iterable[Tool]) -> str:
92
85
  """Render a human-readable description of tools for builtin agent prompts."""
93
- if not tools:
86
+ agent_tools = [tool_item for tool_item in tools if tool_item.agent_use]
87
+ if not agent_tools:
94
88
  return ""
95
89
  lines = []
96
- for tool_item in tools:
90
+ for tool_item in agent_tools:
97
91
  line = f"- {_tool_signature(tool_item)}"
98
92
  if tool_item.description:
99
93
  line += f": {tool_item.description}"
@@ -101,21 +95,6 @@ def render_tools_prompt(tools: Iterable[Tool]) -> str:
101
95
  return f"<available_tools>\n{'\n'.join(lines)}\n</available_tools>"
102
96
 
103
97
 
104
- def completion_tools(tools: Iterable[Tool]) -> list[ChatCompletionToolParam]:
105
- """Build any-llm completion tool payloads from Bub tools."""
106
- return [
107
- {
108
- "type": "function",
109
- "function": {
110
- "name": tool_item.name,
111
- "description": tool_item.description,
112
- "parameters": tool_item.parameters,
113
- },
114
- }
115
- for tool_item in tools
116
- ]
117
-
118
-
119
98
  def _raise_for_failed_shell(returncode: int | None, output: str) -> None:
120
99
  if returncode in (None, 0):
121
100
  return
@@ -376,7 +355,7 @@ async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> str:
376
355
  return output
377
356
 
378
357
 
379
- @tool(name="help")
358
+ @tool(name="help", agent_use=False)
380
359
  def show_help() -> str:
381
360
  """Show a help message."""
382
361
  return (
@@ -399,7 +378,7 @@ def show_help() -> str:
399
378
  )
400
379
 
401
380
 
402
- @tool(name="quit", context=True)
381
+ @tool(name="quit", context=True, agent_use=False)
403
382
  async def quit_tool(*, context: ToolContext) -> str:
404
383
  """Abort the tasks of the current session. DO NOT use it in a normal workflow."""
405
384
  agent = _get_agent(context)
@@ -409,7 +388,7 @@ async def quit_tool(*, context: ToolContext) -> str:
409
388
  return "Session tasks stopped."
410
389
 
411
390
 
412
- @tool(name="model", context=True)
391
+ @tool(name="model", context=True, agent_use=False)
413
392
  async def set_model(model_id: str, *, context: ToolContext) -> str:
414
393
  """Switch the model for THIS session. Invoke as the `,model <model_id>` command.
415
394
 
@@ -425,6 +404,17 @@ async def set_model(model_id: str, *, context: ToolContext) -> str:
425
404
  return f"Session model set to {model_id} (applies from the next turn)."
426
405
 
427
406
 
407
+ @tool(name="reasoning_effort", context=True, agent_use=False)
408
+ async def set_reasoning_effort(reasoning_effort: str, *, context: ToolContext) -> str:
409
+ """Set the reasoning effort for this session starting from the next turn."""
410
+ reasoning_effort = reasoning_effort.strip()
411
+ if not reasoning_effort:
412
+ raise ValueError("reasoning_effort must not be empty")
413
+ context.state["reasoning_effort"] = reasoning_effort
414
+ await context.tape.append_event("reasoning_effort_switch", {"reasoning_effort": reasoning_effort})
415
+ return f"Session reasoning effort set to {reasoning_effort} (applies from the next turn)."
416
+
417
+
428
418
  def _resolve_path(context: ToolContext, raw_path: str) -> Path:
429
419
  workspace = context.state.get("_runtime_workspace")
430
420
  path = Path(raw_path).expanduser()
@@ -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)