bub 0.4.1__tar.gz → 0.4.2__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 (88) hide show
  1. {bub-0.4.1 → bub-0.4.2}/PKG-INFO +2 -1
  2. {bub-0.4.1 → bub-0.4.2}/README.md +1 -0
  3. {bub-0.4.1 → bub-0.4.2}/src/bub/_version.py +2 -2
  4. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/agent.py +1 -2
  5. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/cli.py +7 -0
  6. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/hook_impl.py +14 -4
  7. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/model_runner.py +20 -11
  8. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/settings.py +3 -2
  9. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/tools.py +17 -27
  10. {bub-0.4.1 → bub-0.4.2}/src/bub/tools.py +31 -2
  11. {bub-0.4.1 → bub-0.4.2}/tests/test_agent_hooks.py +1 -1
  12. {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_agent.py +35 -0
  13. {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_cli.py +45 -0
  14. {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_hook_impl.py +13 -0
  15. {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_model_runner.py +59 -0
  16. {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_tools.py +40 -43
  17. {bub-0.4.1 → bub-0.4.2}/tests/test_settings.py +8 -1
  18. {bub-0.4.1 → bub-0.4.2}/tests/test_tools.py +64 -1
  19. {bub-0.4.1 → bub-0.4.2}/.gitignore +0 -0
  20. {bub-0.4.1 → bub-0.4.2}/LICENSE +0 -0
  21. {bub-0.4.1 → bub-0.4.2}/pyproject.toml +0 -0
  22. {bub-0.4.1 → bub-0.4.2}/src/bub/__init__.py +0 -0
  23. {bub-0.4.1 → bub-0.4.2}/src/bub/__main__.py +0 -0
  24. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/__init__.py +0 -0
  25. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/auth.py +0 -0
  26. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/codex_provider.py +0 -0
  27. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/context.py +0 -0
  28. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/shell_manager.py +0 -0
  29. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/steering.py +0 -0
  30. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/store.py +0 -0
  31. {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/tape.py +0 -0
  32. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/__init__.py +0 -0
  33. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/admission.py +0 -0
  34. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/base.py +0 -0
  35. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/__init__.py +0 -0
  36. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/ansi_bridge.py +0 -0
  37. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/renderer.py +0 -0
  38. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/terminal_output.py +0 -0
  39. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/writers.py +0 -0
  40. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/contracts.py +0 -0
  41. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/handler.py +0 -0
  42. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/manager.py +0 -0
  43. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/message.py +0 -0
  44. {bub-0.4.1 → bub-0.4.2}/src/bub/channels/telegram.py +0 -0
  45. {bub-0.4.1 → bub-0.4.2}/src/bub/configure.py +0 -0
  46. {bub-0.4.1 → bub-0.4.2}/src/bub/envelope.py +0 -0
  47. {bub-0.4.1 → bub-0.4.2}/src/bub/errors.py +0 -0
  48. {bub-0.4.1 → bub-0.4.2}/src/bub/framework.py +0 -0
  49. {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/__init__.py +0 -0
  50. {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/interception.py +0 -0
  51. {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/runtime.py +0 -0
  52. {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/specs.py +0 -0
  53. {bub-0.4.1 → bub-0.4.2}/src/bub/inquirer.py +0 -0
  54. {bub-0.4.1 → bub-0.4.2}/src/bub/model_selection.py +0 -0
  55. {bub-0.4.1 → bub-0.4.2}/src/bub/skills.py +0 -0
  56. {bub-0.4.1 → bub-0.4.2}/src/bub/streaming.py +0 -0
  57. {bub-0.4.1 → bub-0.4.2}/src/bub/tape.py +0 -0
  58. {bub-0.4.1 → bub-0.4.2}/src/bub/turn.py +0 -0
  59. {bub-0.4.1 → bub-0.4.2}/src/bub/types.py +0 -0
  60. {bub-0.4.1 → bub-0.4.2}/src/bub/utils.py +0 -0
  61. {bub-0.4.1 → bub-0.4.2}/src/skills/README.md +0 -0
  62. {bub-0.4.1 → bub-0.4.2}/src/skills/gh/SKILL.md +0 -0
  63. {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/SKILL.md +0 -0
  64. {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/license.txt +0 -0
  65. {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  66. {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  67. {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/SKILL.md +0 -0
  68. {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  69. {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/scripts/telegram_rich.py +0 -0
  70. {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/scripts/telegram_send.py +0 -0
  71. {bub-0.4.1 → bub-0.4.2}/tests/conftest.py +0 -0
  72. {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_codex.py +0 -0
  73. {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_tape.py +0 -0
  74. {bub-0.4.1 → bub-0.4.2}/tests/test_channels.py +0 -0
  75. {bub-0.4.1 → bub-0.4.2}/tests/test_cli_help.py +0 -0
  76. {bub-0.4.1 → bub-0.4.2}/tests/test_cli_renderer.py +0 -0
  77. {bub-0.4.1 → bub-0.4.2}/tests/test_configure.py +0 -0
  78. {bub-0.4.1 → bub-0.4.2}/tests/test_envelope.py +0 -0
  79. {bub-0.4.1 → bub-0.4.2}/tests/test_file_tape_store_entry_ids.py +0 -0
  80. {bub-0.4.1 → bub-0.4.2}/tests/test_fork_store_merge_back.py +0 -0
  81. {bub-0.4.1 → bub-0.4.2}/tests/test_framework.py +0 -0
  82. {bub-0.4.1 → bub-0.4.2}/tests/test_hook_runtime.py +0 -0
  83. {bub-0.4.1 → bub-0.4.2}/tests/test_image_message.py +0 -0
  84. {bub-0.4.1 → bub-0.4.2}/tests/test_skills.py +0 -0
  85. {bub-0.4.1 → bub-0.4.2}/tests/test_subagent_tool.py +0 -0
  86. {bub-0.4.1 → bub-0.4.2}/tests/test_tape_search_output.py +0 -0
  87. {bub-0.4.1 → bub-0.4.2}/tests/test_types.py +0 -0
  88. {bub-0.4.1 → bub-0.4.2}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -153,6 +153,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
153
153
  | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
154
154
  | `BUB_API_BASE` | — | Custom provider endpoint |
155
155
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
156
+ | `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
156
157
  | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
157
158
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
158
159
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
@@ -116,6 +116,7 @@ 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_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
119
120
  | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
120
121
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
121
122
  | `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
@@ -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.2'
22
+ __version_tuple__ = version_tuple = (0, 4, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -32,6 +32,7 @@ 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
@@ -352,8 +353,6 @@ class Agent:
352
353
  allowed_skills: set[str] | None,
353
354
  tools: list[Tool],
354
355
  ) -> AsyncStreamEvents:
355
- from bub.builtin.tools import model_tools
356
-
357
356
  system_prompt = self._system_prompt(
358
357
  prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
359
358
  )
@@ -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
 
@@ -90,6 +90,16 @@ class BuiltinImpl:
90
90
  return str(model) if model else None
91
91
  return None
92
92
 
93
+ async def _recover_session_reasoning_effort(self, session_id: str) -> str | None:
94
+ """Recover the latest per-session reasoning effort override."""
95
+ session = self._get_agent().tape.session_tape(session_id, self.framework.workspace)
96
+ entries = list(await session.store.fetch_all(session.query().kinds("event")))
97
+ for entry in reversed(entries):
98
+ if entry.kind == "event" and entry.payload.get("name") == "reasoning_effort_switch":
99
+ reasoning_effort = (entry.payload.get("data") or {}).get("reasoning_effort")
100
+ return str(reasoning_effort) if reasoning_effort else None
101
+ return None
102
+
93
103
  @staticmethod
94
104
  async def _discard_message(_: ChannelMessage) -> None:
95
105
  return
@@ -149,6 +159,8 @@ class BuiltinImpl:
149
159
  # fresh/unknown session never inherits another session's model.
150
160
  if model := await self._recover_session_model(session_id):
151
161
  state["model"] = model
162
+ if reasoning_effort := await self._recover_session_reasoning_effort(session_id):
163
+ state["reasoning_effort"] = reasoning_effort
152
164
  if model := field_of(message, "context", {}).get("model"):
153
165
  state["model"] = model
154
166
  if thread_id := field_of(message, "context", {}).get("thread_id"):
@@ -161,9 +173,8 @@ class BuiltinImpl:
161
173
  lifespan = field_of(message, "lifespan")
162
174
  if lifespan is not None:
163
175
  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.
176
+ # Per-session completion overrides are persisted by their tools as tape
177
+ # events, so nothing to write here — this hook only closes the lifespan.
167
178
 
168
179
  @hookimpl
169
180
  async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> str | list[dict]:
@@ -248,7 +259,6 @@ class BuiltinImpl:
248
259
  "Channels",
249
260
  choices=available_channels,
250
261
  enabled=default_channels,
251
- validate=lambda values: True if values else "Select at least one channel.",
252
262
  )
253
263
 
254
264
  stream_output = bub_inquirer.ask_confirm("Stream output", default=bool(current_config.get("stream_output")))
@@ -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
@@ -60,6 +60,7 @@ class AgentSettings(Settings):
60
60
  max_tokens: int = DEFAULT_MAX_TOKENS
61
61
  model_timeout_seconds: int | None = None
62
62
  client_args: dict[str, Any] = Field(default_factory=dict)
63
+ completion_args: dict[str, Any] = Field(default_factory=dict)
63
64
  verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
64
65
 
65
66
  @classmethod
@@ -79,9 +80,9 @@ class AgentSettings(Settings):
79
80
  file_secret_settings,
80
81
  )
81
82
 
82
- @field_validator("client_args", mode="before")
83
+ @field_validator("client_args", "completion_args", mode="before")
83
84
  @classmethod
84
- def default_client_args(cls, value: Any) -> Any:
85
+ def default_dict_args(cls, value: Any) -> Any:
85
86
  return {} if value is None else value
86
87
 
87
88
  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()
@@ -6,7 +6,7 @@ import contextvars
6
6
  import inspect
7
7
  import json
8
8
  import time
9
- from collections.abc import Awaitable, Callable, Sequence
9
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
10
10
  from dataclasses import dataclass, field, replace
11
11
  from typing import TYPE_CHECKING, Any, Protocol, overload
12
12
 
@@ -94,10 +94,22 @@ class Tool:
94
94
  description: str = ""
95
95
  parameters: dict[str, Any] = field(default_factory=dict)
96
96
  context: bool = False
97
+ agent_use: bool = True
97
98
 
98
99
  def run(self, *args: Any, **kwargs: Any) -> Any:
99
100
  return self.handler(*args, **kwargs)
100
101
 
102
+ def to_schema(self) -> dict[str, Any]:
103
+ """Build an any-llm completion tool payload."""
104
+ return {
105
+ "type": "function",
106
+ "function": {
107
+ "name": self.name,
108
+ "description": self.description,
109
+ "parameters": self.parameters,
110
+ },
111
+ }
112
+
101
113
  @classmethod
102
114
  def from_callable(
103
115
  cls,
@@ -106,6 +118,7 @@ class Tool:
106
118
  name: str | None = None,
107
119
  description: str | None = None,
108
120
  context: bool = False,
121
+ agent_use: bool = True,
109
122
  ) -> Tool:
110
123
  signature = inspect.signature(func)
111
124
  if context and "context" not in signature.parameters:
@@ -129,9 +142,15 @@ class Tool:
129
142
  parameters=parameters,
130
143
  handler=validated,
131
144
  context=context,
145
+ agent_use=agent_use,
132
146
  )
133
147
 
134
148
 
149
+ def model_tools(tools: Iterable[Tool]) -> list[Tool]:
150
+ """Convert agent-enabled runtime tools into model-safe aliases."""
151
+ return [replace(tool_item, name=tool_item.name.replace(".", "_")) for tool_item in tools if tool_item.agent_use]
152
+
153
+
135
154
  @dataclass(frozen=True)
136
155
  class ToolExecution:
137
156
  tool_results: list[Any] = field(default_factory=list)
@@ -405,6 +424,7 @@ def tool(
405
424
  model: type[BaseModel] | None = ...,
406
425
  description: str | None = ...,
407
426
  context: bool = ...,
427
+ agent_use: bool = ...,
408
428
  ) -> Tool: ...
409
429
 
410
430
 
@@ -416,6 +436,7 @@ def tool(
416
436
  model: type[BaseModel] | None = ...,
417
437
  description: str | None = ...,
418
438
  context: bool = ...,
439
+ agent_use: bool = ...,
419
440
  ) -> Callable[[Callable], Tool]: ...
420
441
 
421
442
 
@@ -426,6 +447,7 @@ def tool(
426
447
  model: type[BaseModel] | None = None,
427
448
  description: str | None = None,
428
449
  context: bool = False,
450
+ agent_use: bool = True,
429
451
  ) -> Tool | Callable[[Callable], Tool]:
430
452
  """Decorator to convert a function into a Tool instance."""
431
453
 
@@ -447,9 +469,16 @@ def tool(
447
469
  parameters=model.model_json_schema(),
448
470
  handler=handler,
449
471
  context=context,
472
+ agent_use=agent_use,
450
473
  )
451
474
  else:
452
- result = Tool.from_callable(func, name=name, description=description, context=context)
475
+ result = Tool.from_callable(
476
+ func,
477
+ name=name,
478
+ description=description,
479
+ context=context,
480
+ agent_use=agent_use,
481
+ )
453
482
  tool_instance = _add_logging(result)
454
483
  REGISTRY[tool_instance.name] = tool_instance
455
484
  return tool_instance
@@ -254,7 +254,7 @@ class TestModelRunnerHookIntegration:
254
254
  from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, TapeContext
255
255
 
256
256
  class FakeRunner(ModelRunner):
257
- async def completion_response(self, *, model, messages, tools, max_tokens=None):
257
+ async def completion_response(self, *, model, messages, tools, max_tokens=None, reasoning_effort=None):
258
258
  captured.update(model=model, max_tokens=max_tokens)
259
259
 
260
260
  async def chunks():
@@ -361,6 +361,41 @@ async def test_agent_run_resolves_allowed_tool_aliases_and_limits_prompt() -> No
361
361
  assert "tests_denied_agent_tool" not in system_prompt
362
362
 
363
363
 
364
+ @pytest.mark.asyncio
365
+ async def test_agent_run_excludes_tools_disabled_for_agent_use() -> None:
366
+ visible_name = "tests.visible_agent_tool"
367
+ internal_name = "tests.internal_agent_tool"
368
+ REGISTRY.pop(visible_name, None)
369
+ REGISTRY.pop(internal_name, None)
370
+
371
+ @tool(name=visible_name, description="Visible tool")
372
+ def visible_agent_tool() -> str:
373
+ return "visible"
374
+
375
+ @tool(name=internal_name, description="Internal tool", agent_use=False)
376
+ def internal_agent_tool() -> str:
377
+ return "internal"
378
+
379
+ agent = _make_agent()
380
+ fork_capture = _ForkCapture()
381
+ agent.tape = _FakeTapeFactory(fork_capture) # type: ignore[assignment]
382
+
383
+ result = await agent.run_stream(
384
+ session_id="user/s1",
385
+ prompt="hello",
386
+ state={"_runtime_workspace": "/tmp"}, # noqa: S108
387
+ allowed_tools=[visible_name, internal_name],
388
+ )
389
+ [event async for event in result]
390
+
391
+ completion_kwargs = _model_runner(agent).completion_kwargs
392
+ assert completion_kwargs is not None
393
+ assert [tool.name for tool in completion_kwargs["tools"]] == ["tests_visible_agent_tool"]
394
+ system_prompt = completion_kwargs["messages"][0]["content"]
395
+ assert "tests_visible_agent_tool" in system_prompt
396
+ assert "tests_internal_agent_tool" not in system_prompt
397
+
398
+
364
399
  @pytest.mark.asyncio
365
400
  async def test_agent_run_rejects_unknown_allowed_tools() -> None:
366
401
  agent = _make_agent()
@@ -171,6 +171,35 @@ def test_onboard_collects_builtin_runtime_config(tmp_path: Path, monkeypatch) ->
171
171
  }
172
172
 
173
173
 
174
+ def test_onboard_allows_no_channels(tmp_path: Path, monkeypatch) -> None:
175
+ config_file = tmp_path / "config.yml"
176
+ checkbox_validate: list[object] = []
177
+
178
+ with patch.dict(os.environ, {}, clear=True):
179
+ monkeypatch.chdir(tmp_path)
180
+ framework = BubFramework(config_file=config_file)
181
+ framework.load_hooks()
182
+ app = framework.create_cli_app()
183
+
184
+ monkeypatch.setattr(bub_inquirer, "ask_text", lambda message, default="": default)
185
+ monkeypatch.setattr(bub_inquirer, "ask_fuzzy", lambda message, choices, default=None: default)
186
+
187
+ def ask_checkbox(message, choices, enabled=None, validate=None):
188
+ checkbox_validate.append(validate)
189
+ return []
190
+
191
+ monkeypatch.setattr(bub_inquirer, "ask_checkbox", ask_checkbox)
192
+ monkeypatch.setattr(bub_inquirer, "ask_confirm", lambda message, default=False: default)
193
+ monkeypatch.setattr(bub_inquirer, "ask_secret", lambda message: "")
194
+
195
+ result = CliRunner().invoke(app, ["onboard"])
196
+ loaded = configure.load(config_file)
197
+
198
+ assert result.exit_code == 0
199
+ assert checkbox_validate == [None]
200
+ assert loaded["enabled_channels"] == ""
201
+
202
+
174
203
  def test_onboard_aborts_immediately_when_builtin_prompt_is_interrupted(tmp_path: Path, monkeypatch) -> None:
175
204
  config_file = tmp_path / "config.yml"
176
205
  asked_messages: list[str] = []
@@ -232,6 +261,22 @@ def test_onboard_aborts_immediately_when_builtin_prompt_is_interrupted(tmp_path:
232
261
  assert not config_file.exists()
233
262
 
234
263
 
264
+ def test_gateway_fails_when_no_channels_are_enabled(tmp_path: Path, monkeypatch) -> None:
265
+ config_file = tmp_path / "config.yml"
266
+
267
+ with patch.dict(os.environ, {}, clear=True):
268
+ monkeypatch.chdir(tmp_path)
269
+ configure.save(config_file, {"enabled_channels": ""})
270
+ framework = BubFramework(config_file=config_file)
271
+ framework.load_hooks()
272
+ app = framework.create_cli_app()
273
+
274
+ result = CliRunner().invoke(app, ["gateway"])
275
+
276
+ assert result.exit_code == 1
277
+ assert "No channels are enabled." in result.output
278
+
279
+
235
280
  def test_run_command_processes_inbound_inside_framework_runtime(tmp_path: Path) -> None:
236
281
  config_file = tmp_path / "config.yml"
237
282
  framework = BubFramework(config_file=config_file)
@@ -142,6 +142,19 @@ async def test_load_state_injects_model_recorded_on_session_tape(tmp_path: Path)
142
142
  assert state["model"] == "openai:gpt-4o"
143
143
 
144
144
 
145
+ @pytest.mark.asyncio
146
+ async def test_load_state_injects_reasoning_effort_recorded_on_session_tape(tmp_path: Path) -> None:
147
+ _, impl, agent = _build_impl(tmp_path)
148
+ session = agent.tape.session_tape("resolved-session", impl.framework.workspace)
149
+ await session.append_event("reasoning_effort_switch", {"reasoning_effort": "high"})
150
+
151
+ message = ChannelMessage(session_id="session", channel="cli", chat_id="room", content="hello")
152
+
153
+ state = await impl.load_state(message=message, session_id="resolved-session")
154
+
155
+ assert state["reasoning_effort"] == "high"
156
+
157
+
145
158
  @pytest.mark.asyncio
146
159
  async def test_load_state_does_not_inject_model_for_unknown_session(tmp_path: Path) -> None:
147
160
  """A session with nothing recorded on its tape must not inherit any model (no leakage)."""
@@ -136,3 +136,62 @@ async def test_anthropic_prompt_caching_is_requested() -> None:
136
136
  assert llm.completion_kwargs["stream"] is True
137
137
  assert llm.completion_kwargs["cache_control"] == {"type": "ephemeral"}
138
138
  assert "stream_options" not in llm.completion_kwargs
139
+
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_run_applies_reasoning_effort_from_tape_state(tmp_path: Path) -> None:
143
+ tape = Tape(
144
+ tmp_path,
145
+ AsyncTapeStoreAdapter(InMemoryTapeStore()),
146
+ TapeContext(state={"reasoning_effort": "high"}),
147
+ ).scoped("test-tape")
148
+ llm = _FakeStreamingOpenAIProvider()
149
+ runner = _FakeOpenAIModelRunner(
150
+ AgentSettings.model_construct(
151
+ model="openai:gpt-test",
152
+ max_tokens=100,
153
+ model_timeout_seconds=None,
154
+ completion_args={"reasoning_effort": "low"},
155
+ ),
156
+ llm,
157
+ )
158
+
159
+ await tape.ensure_bootstrap_anchor()
160
+ events = runner.run(tape=tape, model="gpt-test", tools=[], system_prompt=None, prompt="hello")
161
+ [event async for event in events]
162
+
163
+ assert llm.completion_kwargs is not None
164
+ assert llm.completion_kwargs["reasoning_effort"] == "high"
165
+
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_completion_args_are_forwarded_without_overriding_managed_args() -> None:
169
+ llm = _FakeStreamingOpenAIProvider()
170
+ runner = _FakeOpenAIModelRunner(
171
+ AgentSettings.model_construct(
172
+ model="openai:gpt-test",
173
+ max_tokens=100,
174
+ completion_args={
175
+ "reasoning_effort": "high",
176
+ "model": "ignored-model",
177
+ "max_tokens": 1,
178
+ "stream": False,
179
+ "stream_options": {"include_usage": False},
180
+ },
181
+ ),
182
+ llm,
183
+ )
184
+
185
+ await runner.completion_response(
186
+ model="gpt-test",
187
+ messages=[{"role": "user", "content": "hello"}],
188
+ tools=[],
189
+ max_tokens=42,
190
+ )
191
+
192
+ assert llm.completion_kwargs is not None
193
+ assert llm.completion_kwargs["reasoning_effort"] == "high"
194
+ assert llm.completion_kwargs["model"] == "gpt-test"
195
+ assert llm.completion_kwargs["max_tokens"] == 42
196
+ assert llm.completion_kwargs["stream"] is True
197
+ assert llm.completion_kwargs["stream_options"] == {"include_usage": True}
@@ -14,13 +14,12 @@ from bub.builtin.tape import Tape
14
14
  from bub.builtin.tools import (
15
15
  bash,
16
16
  bash_output,
17
- completion_tools,
18
17
  kill_bash,
19
- model_tools,
20
18
  quit_tool,
21
19
  render_tools_prompt,
22
20
  resolve_tool_names,
23
21
  set_model,
22
+ set_reasoning_effort,
24
23
  tape_info,
25
24
  )
26
25
  from bub.errors import ErrorKind
@@ -58,47 +57,6 @@ async def test_tape_info_formats_token_cache_hit_rate(tmp_path) -> None:
58
57
  assert "last_token_cache_hit_rate: 37.50%" in result
59
58
 
60
59
 
61
- def test_completion_tools_builds_any_llm_payload() -> None:
62
- parameters = {
63
- "type": "object",
64
- "properties": {"value": {"type": "string"}},
65
- "required": ["value"],
66
- }
67
- sample_tool = Tool(
68
- name="tests_sample_tool",
69
- description="Sample tool",
70
- parameters=parameters,
71
- handler=lambda value: value,
72
- )
73
-
74
- assert completion_tools([sample_tool]) == [
75
- {
76
- "type": "function",
77
- "function": {
78
- "name": "tests_sample_tool",
79
- "description": "Sample tool",
80
- "parameters": parameters,
81
- },
82
- }
83
- ]
84
-
85
-
86
- def test_model_tools_rewrites_dotted_names_without_mutating_original() -> None:
87
- tool_name = "tests.rename_me"
88
- REGISTRY.pop(tool_name, None)
89
-
90
- @tool(name=tool_name, description="rename")
91
- def rename_me(value: str) -> str:
92
- return "ok"
93
-
94
- rewritten = model_tools([rename_me])
95
-
96
- assert [item.name for item in rewritten] == ["tests_rename_me"]
97
- assert rewritten[0].parameters == rename_me.parameters
98
- assert rename_me.name == tool_name
99
- assert "additionalProperties" not in rename_me.parameters
100
-
101
-
102
60
  def test_render_tools_prompt_renders_available_tools_block() -> None:
103
61
  first_name = "tests.prompt_one"
104
62
  second_name = "tests.prompt_two"
@@ -135,6 +93,12 @@ def test_render_tools_prompt_returns_empty_string_for_empty_input() -> None:
135
93
  assert render_tools_prompt([]) == ""
136
94
 
137
95
 
96
+ def test_render_tools_prompt_excludes_tools_disabled_for_agent_use() -> None:
97
+ internal_tool = Tool(name="tests.internal", handler=lambda: None, agent_use=False)
98
+
99
+ assert render_tools_prompt([internal_tool]) == ""
100
+
101
+
138
102
  def test_resolve_tool_names_accepts_runtime_names_and_model_aliases() -> None:
139
103
  dotted_name = "tests.resolve_alias"
140
104
  underscored_name = "tests_with_underscore"
@@ -212,6 +176,39 @@ async def test_set_model_overwrites_previous_model(tmp_path) -> None:
212
176
  assert context.state["model"] == "anthropic:claude-3"
213
177
 
214
178
 
179
+ def test_set_reasoning_effort_is_registered_for_internal_use() -> None:
180
+ assert REGISTRY["reasoning_effort"] is set_reasoning_effort
181
+ assert set_reasoning_effort.context is True
182
+ assert set_reasoning_effort.agent_use is False
183
+ assert set_reasoning_effort.parameters == {
184
+ "type": "object",
185
+ "properties": {"reasoning_effort": {"type": "string"}},
186
+ "required": ["reasoning_effort"],
187
+ }
188
+
189
+
190
+ @pytest.mark.asyncio
191
+ async def test_set_reasoning_effort_writes_state_and_records_on_tape(tmp_path) -> None:
192
+ context = _tool_context(tmp_path)
193
+
194
+ result = await set_reasoning_effort.run(reasoning_effort=" high ", context=context)
195
+
196
+ assert context.state["reasoning_effort"] == "high"
197
+ assert result == "Session reasoning effort set to high (applies from the next turn)."
198
+ entries = list(await context.tape.store.fetch_all(context.tape.query().kinds("event")))
199
+ switches = [
200
+ entry for entry in entries if entry.kind == "event" and entry.payload.get("name") == "reasoning_effort_switch"
201
+ ]
202
+ assert len(switches) == 1
203
+ assert switches[0].payload.get("data") == {"reasoning_effort": "high"}
204
+
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_set_reasoning_effort_rejects_empty_value(tmp_path) -> None:
208
+ with pytest.raises(ValueError, match="must not be empty"):
209
+ await set_reasoning_effort.run(reasoning_effort=" ", context=_tool_context(tmp_path))
210
+
211
+
215
212
  @pytest.mark.asyncio
216
213
  async def test_bash_returns_stdout_for_foreground_command(tmp_path) -> None:
217
214
  result = await bash.run(cmd=_python_shell("print('hello')"), context=_tool_context(tmp_path))
@@ -38,6 +38,7 @@ def test_settings_no_keys_return_none() -> None:
38
38
  assert settings.api_key is None
39
39
  assert settings.api_base is None
40
40
  assert settings.client_args == {}
41
+ assert settings.completion_args == {}
41
42
 
42
43
 
43
44
  def test_settings_provider_names_are_lowercased() -> None:
@@ -74,6 +75,8 @@ client_args:
74
75
  extra_headers:
75
76
  HTTP-Referer: https://openclaw.ai
76
77
  X-Title: OpenClaw
78
+ completion_args:
79
+ reasoning_effort: high
77
80
  """.strip(),
78
81
  )
79
82
 
@@ -87,6 +90,7 @@ client_args:
87
90
  assert settings.client_args == {
88
91
  "extra_headers": {"HTTP-Referer": "https://openclaw.ai", "X-Title": "OpenClaw"},
89
92
  }
93
+ assert settings.completion_args == {"reasoning_effort": "high"}
90
94
 
91
95
 
92
96
  def test_env_settings_override_yaml(load_config) -> None:
@@ -106,6 +110,7 @@ client_args:
106
110
  "BUB_MODEL": "anthropic:claude-3-7-sonnet",
107
111
  "BUB_API_KEY": "sk-env",
108
112
  "BUB_CLIENT_ARGS": '{"extra_headers":{"HTTP-Referer":"https://env.example","X-Title":"Env App"}}',
113
+ "BUB_COMPLETION_ARGS": '{"reasoning_effort":"medium"}',
109
114
  "BUB_MAX_STEPS": "12",
110
115
  },
111
116
  clear=True,
@@ -119,12 +124,14 @@ client_args:
119
124
  assert settings.client_args == {
120
125
  "extra_headers": {"HTTP-Referer": "https://env.example", "X-Title": "Env App"},
121
126
  }
127
+ assert settings.completion_args == {"reasoning_effort": "medium"}
122
128
 
123
129
 
124
130
  def test_settings_client_args_can_be_disabled() -> None:
125
- settings = _settings_with_env({"BUB_CLIENT_ARGS": "null"})
131
+ settings = _settings_with_env({"BUB_CLIENT_ARGS": "null", "BUB_COMPLETION_ARGS": "null"})
126
132
 
127
133
  assert settings.client_args == {}
134
+ assert settings.completion_args == {}
128
135
 
129
136
 
130
137
  def test_load_settings_returns_defaults_without_loaded_config() -> None:
@@ -6,13 +6,61 @@ import pytest
6
6
  from loguru import logger
7
7
  from pydantic import BaseModel
8
8
 
9
- from bub.tools import REGISTRY, tool, tool_call_reporter
9
+ from bub.tools import REGISTRY, Tool, model_tools, tool, tool_call_reporter
10
10
 
11
11
 
12
12
  class EchoInput(BaseModel):
13
13
  value: str
14
14
 
15
15
 
16
+ def test_tool_builds_completion_payload() -> None:
17
+ parameters = {
18
+ "type": "object",
19
+ "properties": {"value": {"type": "string"}},
20
+ "required": ["value"],
21
+ }
22
+ sample_tool = Tool(
23
+ name="tests_sample_tool",
24
+ description="Sample tool",
25
+ parameters=parameters,
26
+ handler=lambda value: value,
27
+ )
28
+
29
+ assert sample_tool.to_schema() == {
30
+ "type": "function",
31
+ "function": {
32
+ "name": "tests_sample_tool",
33
+ "description": "Sample tool",
34
+ "parameters": parameters,
35
+ },
36
+ }
37
+
38
+
39
+ def test_model_tools_rewrites_dotted_names_without_mutating_original() -> None:
40
+ tool_name = "tests.rename_me"
41
+ REGISTRY.pop(tool_name, None)
42
+
43
+ @tool(name=tool_name, description="rename")
44
+ def rename_me(value: str) -> str:
45
+ return "ok"
46
+
47
+ rewritten = model_tools([rename_me])
48
+
49
+ assert [item.name for item in rewritten] == ["tests_rename_me"]
50
+ assert rewritten[0].parameters == rename_me.parameters
51
+ assert rename_me.name == tool_name
52
+ assert "additionalProperties" not in rename_me.parameters
53
+
54
+
55
+ def test_model_tools_excludes_tools_disabled_for_agent_use() -> None:
56
+ visible_tool = Tool(name="tests.visible", handler=lambda: None)
57
+ internal_tool = Tool(name="tests.internal", handler=lambda: None, agent_use=False)
58
+
59
+ rewritten = model_tools([visible_tool, internal_tool])
60
+
61
+ assert [item.name for item in rewritten] == ["tests_visible"]
62
+
63
+
16
64
  @pytest.mark.asyncio
17
65
  async def test_tool_decorator_registers_tool_and_preserves_metadata() -> None:
18
66
  tool_name = "tests.sync_tool"
@@ -24,10 +72,25 @@ async def test_tool_decorator_registers_tool_and_preserves_metadata() -> None:
24
72
 
25
73
  assert sync_tool.name == tool_name
26
74
  assert sync_tool.description == "Sync test tool"
75
+ assert sync_tool.agent_use is True
27
76
  assert REGISTRY[tool_name] is sync_tool
28
77
  assert await sync_tool.run(value="hello") == "HELLO"
29
78
 
30
79
 
80
+ @pytest.mark.asyncio
81
+ async def test_tool_decorator_can_disable_agent_use_without_disabling_direct_calls() -> None:
82
+ tool_name = "tests.internal_tool"
83
+ REGISTRY.pop(tool_name, None)
84
+
85
+ @tool(name=tool_name, agent_use=False)
86
+ def internal_tool(value: str) -> str:
87
+ return value.upper()
88
+
89
+ assert internal_tool.agent_use is False
90
+ assert REGISTRY[tool_name] is internal_tool
91
+ assert await internal_tool.run("hello") == "HELLO"
92
+
93
+
31
94
  @pytest.mark.asyncio
32
95
  async def test_tool_wrapper_logs_and_omits_context_from_log_payload(monkeypatch: pytest.MonkeyPatch) -> None:
33
96
  tool_name = "tests.async_tool"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes