bub 0.4.0__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.0 → bub-0.4.2}/PKG-INFO +2 -1
  2. {bub-0.4.0 → bub-0.4.2}/README.md +1 -0
  3. {bub-0.4.0 → bub-0.4.2}/src/bub/_version.py +2 -2
  4. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/agent.py +1 -2
  5. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/cli.py +7 -0
  6. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/codex_provider.py +8 -2
  7. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/hook_impl.py +14 -4
  8. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/model_runner.py +29 -23
  9. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/settings.py +3 -2
  10. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/tape.py +24 -6
  11. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/tools.py +27 -31
  12. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/cli/__init__.py +177 -109
  13. bub-0.4.2/src/bub/channels/cli/ansi_bridge.py +23 -0
  14. bub-0.4.2/src/bub/channels/cli/terminal_output.py +140 -0
  15. bub-0.4.2/src/bub/channels/cli/writers.py +42 -0
  16. {bub-0.4.0 → bub-0.4.2}/src/bub/tools.py +42 -8
  17. {bub-0.4.0 → bub-0.4.2}/tests/test_agent_hooks.py +1 -1
  18. {bub-0.4.0 → bub-0.4.2}/tests/test_builtin_agent.py +35 -0
  19. {bub-0.4.0 → bub-0.4.2}/tests/test_builtin_cli.py +45 -0
  20. {bub-0.4.0 → bub-0.4.2}/tests/test_builtin_codex.py +8 -1
  21. {bub-0.4.0 → bub-0.4.2}/tests/test_builtin_hook_impl.py +13 -0
  22. {bub-0.4.0 → bub-0.4.2}/tests/test_builtin_model_runner.py +102 -0
  23. {bub-0.4.0 → bub-0.4.2}/tests/test_builtin_tape.py +38 -0
  24. {bub-0.4.0 → bub-0.4.2}/tests/test_builtin_tools.py +57 -38
  25. {bub-0.4.0 → bub-0.4.2}/tests/test_channels.py +405 -44
  26. {bub-0.4.0 → bub-0.4.2}/tests/test_settings.py +8 -1
  27. {bub-0.4.0 → bub-0.4.2}/tests/test_tools.py +64 -1
  28. {bub-0.4.0 → bub-0.4.2}/.gitignore +0 -0
  29. {bub-0.4.0 → bub-0.4.2}/LICENSE +0 -0
  30. {bub-0.4.0 → bub-0.4.2}/pyproject.toml +0 -0
  31. {bub-0.4.0 → bub-0.4.2}/src/bub/__init__.py +0 -0
  32. {bub-0.4.0 → bub-0.4.2}/src/bub/__main__.py +0 -0
  33. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/__init__.py +0 -0
  34. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/auth.py +0 -0
  35. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/context.py +0 -0
  36. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/shell_manager.py +0 -0
  37. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/steering.py +0 -0
  38. {bub-0.4.0 → bub-0.4.2}/src/bub/builtin/store.py +0 -0
  39. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/__init__.py +0 -0
  40. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/admission.py +0 -0
  41. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/base.py +0 -0
  42. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/cli/renderer.py +0 -0
  43. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/contracts.py +0 -0
  44. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/handler.py +0 -0
  45. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/manager.py +0 -0
  46. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/message.py +0 -0
  47. {bub-0.4.0 → bub-0.4.2}/src/bub/channels/telegram.py +0 -0
  48. {bub-0.4.0 → bub-0.4.2}/src/bub/configure.py +0 -0
  49. {bub-0.4.0 → bub-0.4.2}/src/bub/envelope.py +0 -0
  50. {bub-0.4.0 → bub-0.4.2}/src/bub/errors.py +0 -0
  51. {bub-0.4.0 → bub-0.4.2}/src/bub/framework.py +0 -0
  52. {bub-0.4.0 → bub-0.4.2}/src/bub/hooks/__init__.py +0 -0
  53. {bub-0.4.0 → bub-0.4.2}/src/bub/hooks/interception.py +0 -0
  54. {bub-0.4.0 → bub-0.4.2}/src/bub/hooks/runtime.py +0 -0
  55. {bub-0.4.0 → bub-0.4.2}/src/bub/hooks/specs.py +0 -0
  56. {bub-0.4.0 → bub-0.4.2}/src/bub/inquirer.py +0 -0
  57. {bub-0.4.0 → bub-0.4.2}/src/bub/model_selection.py +0 -0
  58. {bub-0.4.0 → bub-0.4.2}/src/bub/skills.py +0 -0
  59. {bub-0.4.0 → bub-0.4.2}/src/bub/streaming.py +0 -0
  60. {bub-0.4.0 → bub-0.4.2}/src/bub/tape.py +0 -0
  61. {bub-0.4.0 → bub-0.4.2}/src/bub/turn.py +0 -0
  62. {bub-0.4.0 → bub-0.4.2}/src/bub/types.py +0 -0
  63. {bub-0.4.0 → bub-0.4.2}/src/bub/utils.py +0 -0
  64. {bub-0.4.0 → bub-0.4.2}/src/skills/README.md +0 -0
  65. {bub-0.4.0 → bub-0.4.2}/src/skills/gh/SKILL.md +0 -0
  66. {bub-0.4.0 → bub-0.4.2}/src/skills/skill-creator/SKILL.md +0 -0
  67. {bub-0.4.0 → bub-0.4.2}/src/skills/skill-creator/license.txt +0 -0
  68. {bub-0.4.0 → bub-0.4.2}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  69. {bub-0.4.0 → bub-0.4.2}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  70. {bub-0.4.0 → bub-0.4.2}/src/skills/telegram/SKILL.md +0 -0
  71. {bub-0.4.0 → bub-0.4.2}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  72. {bub-0.4.0 → bub-0.4.2}/src/skills/telegram/scripts/telegram_rich.py +0 -0
  73. {bub-0.4.0 → bub-0.4.2}/src/skills/telegram/scripts/telegram_send.py +0 -0
  74. {bub-0.4.0 → bub-0.4.2}/tests/conftest.py +0 -0
  75. {bub-0.4.0 → bub-0.4.2}/tests/test_cli_help.py +0 -0
  76. {bub-0.4.0 → bub-0.4.2}/tests/test_cli_renderer.py +0 -0
  77. {bub-0.4.0 → bub-0.4.2}/tests/test_configure.py +0 -0
  78. {bub-0.4.0 → bub-0.4.2}/tests/test_envelope.py +0 -0
  79. {bub-0.4.0 → bub-0.4.2}/tests/test_file_tape_store_entry_ids.py +0 -0
  80. {bub-0.4.0 → bub-0.4.2}/tests/test_fork_store_merge_back.py +0 -0
  81. {bub-0.4.0 → bub-0.4.2}/tests/test_framework.py +0 -0
  82. {bub-0.4.0 → bub-0.4.2}/tests/test_hook_runtime.py +0 -0
  83. {bub-0.4.0 → bub-0.4.2}/tests/test_image_message.py +0 -0
  84. {bub-0.4.0 → bub-0.4.2}/tests/test_skills.py +0 -0
  85. {bub-0.4.0 → bub-0.4.2}/tests/test_subagent_tool.py +0 -0
  86. {bub-0.4.0 → bub-0.4.2}/tests/test_tape_search_output.py +0 -0
  87. {bub-0.4.0 → bub-0.4.2}/tests/test_types.py +0 -0
  88. {bub-0.4.0 → 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.0
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.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
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
 
@@ -365,22 +365,28 @@ class CodexCompletionChunkMapper:
365
365
  })
366
366
 
367
367
 
368
- def _completion_usage_from_response_usage(usage: Any) -> dict[str, int]:
368
+ def _completion_usage_from_response_usage(usage: Any) -> dict[str, Any]:
369
369
  payload = usage.model_dump(exclude_none=True) if hasattr(usage, "model_dump") else usage
370
370
  if not isinstance(payload, dict):
371
371
  payload = {
372
372
  "input_tokens": getattr(usage, "input_tokens", 0),
373
373
  "output_tokens": getattr(usage, "output_tokens", 0),
374
374
  "total_tokens": getattr(usage, "total_tokens", 0),
375
+ "input_tokens_details": getattr(usage, "input_tokens_details", None),
375
376
  }
376
377
  prompt_tokens = int(payload.get("input_tokens") or payload.get("prompt_tokens") or 0)
377
378
  completion_tokens = int(payload.get("output_tokens") or payload.get("completion_tokens") or 0)
378
379
  total_tokens = int(payload.get("total_tokens") or prompt_tokens + completion_tokens)
379
- return {
380
+ completion_usage: dict[str, Any] = {
380
381
  "prompt_tokens": prompt_tokens,
381
382
  "completion_tokens": completion_tokens,
382
383
  "total_tokens": total_tokens,
383
384
  }
385
+ input_details = _mapping_from_value(payload.get("input_tokens_details"))
386
+ cached_tokens = input_details.get("cached_tokens")
387
+ if isinstance(cached_tokens, int) and not isinstance(cached_tokens, bool):
388
+ completion_usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
389
+ return completion_usage
384
390
 
385
391
 
386
392
  def _string_attr(obj: Any, name: str) -> str:
@@ -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")))
@@ -11,6 +11,7 @@ from typing import Any, Literal, cast
11
11
 
12
12
  from any_llm import AnyLLM
13
13
  from any_llm.constants import LLMProvider
14
+ from any_llm.providers.anthropic.base import BaseAnthropicProvider
14
15
  from any_llm.providers.openai.base import BaseOpenAIProvider
15
16
  from any_llm.types.completion import (
16
17
  ChatCompletion,
@@ -46,17 +47,13 @@ TOOL_ARGUMENTS_ADAPTER = TypeAdapter(dict[str, Any])
46
47
  CompletionResult = ChatCompletion | ParsedChatCompletion[Any] | AsyncIterator[ChatCompletionChunk]
47
48
 
48
49
 
49
- def _stream_usage_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any] | None:
50
- """Make streaming completions report token usage.
51
-
52
- OpenAI-style streaming responses omit the `usage` block unless the request
53
- sets `stream_options.include_usage`; without it every streamed run records
54
- zero tokens (and zero cost). Only OpenAI-compatible providers accept the
55
- field, so gate on the provider base class — anthropic/gemini reject it.
56
- """
57
- if stream and isinstance(llm, BaseOpenAIProvider):
58
- return {"include_usage": True}
59
- return None
50
+ def _extra_options(llm: AnyLLM, *, stream: bool) -> dict[str, Any]:
51
+ """Return provider-specific extra completion options."""
52
+ if isinstance(llm, BaseAnthropicProvider):
53
+ return {"cache_control": {"type": "ephemeral"}}
54
+ elif stream and isinstance(llm, BaseOpenAIProvider):
55
+ return {"stream_options": {"include_usage": True}}
56
+ return {}
60
57
 
61
58
 
62
59
  class ModelRunner:
@@ -84,25 +81,33 @@ class ModelRunner:
84
81
  return AnyLLM.create(candidate.provider, **client_kwargs)
85
82
 
86
83
  async def completion_response(
87
- 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,
88
91
  ) -> CompletionResult:
89
- from bub.builtin.tools import completion_tools
90
-
91
- tool_payloads = completion_tools(tools) or None
92
+ tool_payloads = [tool.to_schema() for tool in tools] or None
92
93
  completion_messages: list[dict[str, Any] | ChatCompletionMessage] = list(messages)
93
94
  clients = list(self.iter_llm_clients(model))
94
95
  completion_error: Exception | None = None
95
96
  for index, (candidate, llm) in enumerate(clients):
96
97
  try:
97
98
  streaming = llm.SUPPORTS_COMPLETION_STREAMING
98
- return await llm.acompletion(
99
- model=candidate.model_id,
100
- messages=completion_messages,
101
- tools=tool_payloads,
102
- max_tokens=max_tokens if max_tokens is not None else self.settings.max_tokens,
103
- stream=streaming,
104
- stream_options=_stream_usage_options(llm, stream=streaming),
105
- )
99
+ completion_kwargs = {
100
+ **self.settings.completion_args,
101
+ **_extra_options(llm, stream=streaming),
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))
106
111
  except Exception as exc:
107
112
  if completion_error is None:
108
113
  completion_error = exc
@@ -176,6 +181,7 @@ class ModelRunner:
176
181
  messages=list(request.messages),
177
182
  tools=tools,
178
183
  max_tokens=request.max_tokens,
184
+ reasoning_effort=tape.context.state.get("reasoning_effort"),
179
185
  )
180
186
  async for event in self._completion_events(completion, state, output):
181
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,7 +4,7 @@ import contextlib
4
4
  import hashlib
5
5
  import inspect
6
6
  import json
7
- from collections.abc import AsyncGenerator
7
+ from collections.abc import AsyncGenerator, Mapping
8
8
  from dataclasses import asdict, dataclass, field, replace
9
9
  from datetime import UTC, datetime
10
10
  from pathlib import Path
@@ -33,6 +33,7 @@ class TapeInfo:
33
33
  last_anchor: str | None
34
34
  entries_since_last_anchor: int
35
35
  last_token_usage: int | None
36
+ last_token_cache_hit_rate: float | None
36
37
 
37
38
 
38
39
  @dataclass(frozen=True)
@@ -77,13 +78,29 @@ class Tape:
77
78
  last_anchor = None
78
79
  entries_since_last_anchor = len(entries)
79
80
  last_token_usage: int | None = None
81
+ last_token_cache_hit_rate: float | None = None
80
82
  for entry in reversed(entries):
81
83
  if entry.kind == "event" and entry.payload.get("name") == "run":
82
- with contextlib.suppress(AttributeError):
83
- token_usage = entry.payload.get("data", {}).get("usage", {}).get("total_tokens")
84
- if token_usage and isinstance(token_usage, int):
85
- last_token_usage = token_usage
86
- break
84
+ data = entry.payload.get("data")
85
+ usage = data.get("usage") if isinstance(data, Mapping) else None
86
+ if not isinstance(usage, Mapping):
87
+ continue
88
+ token_usage = usage.get("total_tokens")
89
+ if not isinstance(token_usage, int) or isinstance(token_usage, bool):
90
+ continue
91
+ last_token_usage = token_usage
92
+ prompt_tokens = usage.get("prompt_tokens")
93
+ prompt_details = usage.get("prompt_tokens_details")
94
+ cached_tokens = prompt_details.get("cached_tokens") if isinstance(prompt_details, Mapping) else None
95
+ if (
96
+ isinstance(prompt_tokens, int)
97
+ and not isinstance(prompt_tokens, bool)
98
+ and prompt_tokens > 0
99
+ and isinstance(cached_tokens, int)
100
+ and not isinstance(cached_tokens, bool)
101
+ ):
102
+ last_token_cache_hit_rate = cached_tokens / prompt_tokens
103
+ break
87
104
  return TapeInfo(
88
105
  name=self.name,
89
106
  entries=len(entries),
@@ -91,6 +108,7 @@ class Tape:
91
108
  last_anchor=str(last_anchor) if last_anchor else None,
92
109
  entries_since_last_anchor=entries_since_last_anchor,
93
110
  last_token_usage=last_token_usage,
111
+ last_token_cache_hit_rate=last_token_cache_hit_rate,
94
112
  )
95
113
 
96
114
  async def ensure_bootstrap_anchor(self) -> None:
@@ -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
@@ -253,16 +232,20 @@ def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolConte
253
232
 
254
233
 
255
234
  @tool(context=True, name="skill")
256
- def skill_describe(name: str, *, context: ToolContext) -> str:
257
- """Load the skill content by name. Return the location and skill content."""
235
+ def skill_describe(name: str | None = None, *, context: ToolContext) -> str:
236
+ """Load the skill content by name. Return the location and skill content.
237
+ If name is not provided, list all available skills in the current workspace.
238
+ """
258
239
  from bub.utils import workspace_from_state
259
240
 
260
241
  allowed_skills = context.state.get("allowed_skills")
261
- if allowed_skills is not None and name.casefold() not in allowed_skills:
242
+ if allowed_skills is not None and name and name.casefold() not in allowed_skills:
262
243
  return f"(skill '{name}' is not allowed in this context)"
263
244
 
264
245
  workspace = workspace_from_state(context.state)
265
246
  skill_index = {skill.name: skill for skill in discover_skills(workspace)}
247
+ if name is None:
248
+ return "Available skills:\n" + "\n".join(f"- {skill.name}" for skill in skill_index.values())
266
249
  if name.casefold() not in skill_index:
267
250
  return "(no such skill)"
268
251
  skill = skill_index[name.casefold()]
@@ -273,13 +256,15 @@ def skill_describe(name: str, *, context: ToolContext) -> str:
273
256
  async def tape_info(context: ToolContext) -> str:
274
257
  """Get information about the current tape, such as number of entries and anchors."""
275
258
  info = await context.tape.info()
259
+ cache_hit_rate = f"{info.last_token_cache_hit_rate:.2%}" if info.last_token_cache_hit_rate is not None else "None"
276
260
  return (
277
261
  f"name: {info.name}\n"
278
262
  f"entries: {info.entries}\n"
279
263
  f"anchors: {info.anchors}\n"
280
264
  f"last_anchor: {info.last_anchor}\n"
281
265
  f"entries_since_last_anchor: {info.entries_since_last_anchor}\n"
282
- f"last_token_usage: {info.last_token_usage}"
266
+ f"last_token_usage: {info.last_token_usage}\n"
267
+ f"last_token_cache_hit_rate: {cache_hit_rate}"
283
268
  )
284
269
 
285
270
 
@@ -370,7 +355,7 @@ async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> str:
370
355
  return output
371
356
 
372
357
 
373
- @tool(name="help")
358
+ @tool(name="help", agent_use=False)
374
359
  def show_help() -> str:
375
360
  """Show a help message."""
376
361
  return (
@@ -393,7 +378,7 @@ def show_help() -> str:
393
378
  )
394
379
 
395
380
 
396
- @tool(name="quit", context=True)
381
+ @tool(name="quit", context=True, agent_use=False)
397
382
  async def quit_tool(*, context: ToolContext) -> str:
398
383
  """Abort the tasks of the current session. DO NOT use it in a normal workflow."""
399
384
  agent = _get_agent(context)
@@ -403,7 +388,7 @@ async def quit_tool(*, context: ToolContext) -> str:
403
388
  return "Session tasks stopped."
404
389
 
405
390
 
406
- @tool(name="model", context=True)
391
+ @tool(name="model", context=True, agent_use=False)
407
392
  async def set_model(model_id: str, *, context: ToolContext) -> str:
408
393
  """Switch the model for THIS session. Invoke as the `,model <model_id>` command.
409
394
 
@@ -419,6 +404,17 @@ async def set_model(model_id: str, *, context: ToolContext) -> str:
419
404
  return f"Session model set to {model_id} (applies from the next turn)."
420
405
 
421
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
+
422
418
  def _resolve_path(context: ToolContext, raw_path: str) -> Path:
423
419
  workspace = context.state.get("_runtime_workspace")
424
420
  path = Path(raw_path).expanduser()