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.
- {bub-0.4.1 → bub-0.4.2}/PKG-INFO +2 -1
- {bub-0.4.1 → bub-0.4.2}/README.md +1 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/_version.py +2 -2
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/agent.py +1 -2
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/cli.py +7 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/hook_impl.py +14 -4
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/model_runner.py +20 -11
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/settings.py +3 -2
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/tools.py +17 -27
- {bub-0.4.1 → bub-0.4.2}/src/bub/tools.py +31 -2
- {bub-0.4.1 → bub-0.4.2}/tests/test_agent_hooks.py +1 -1
- {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_agent.py +35 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_cli.py +45 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_hook_impl.py +13 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_model_runner.py +59 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_tools.py +40 -43
- {bub-0.4.1 → bub-0.4.2}/tests/test_settings.py +8 -1
- {bub-0.4.1 → bub-0.4.2}/tests/test_tools.py +64 -1
- {bub-0.4.1 → bub-0.4.2}/.gitignore +0 -0
- {bub-0.4.1 → bub-0.4.2}/LICENSE +0 -0
- {bub-0.4.1 → bub-0.4.2}/pyproject.toml +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/__main__.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/auth.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/codex_provider.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/context.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/shell_manager.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/steering.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/store.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/builtin/tape.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/admission.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/base.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/ansi_bridge.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/renderer.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/terminal_output.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/cli/writers.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/contracts.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/handler.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/manager.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/message.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/channels/telegram.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/configure.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/envelope.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/errors.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/framework.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/interception.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/runtime.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/hooks/specs.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/inquirer.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/model_selection.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/skills.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/streaming.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/tape.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/turn.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/types.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/bub/utils.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/README.md +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/gh/SKILL.md +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/SKILL.md +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/scripts/telegram_rich.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/conftest.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_codex.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_builtin_tape.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_channels.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_cli_help.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_cli_renderer.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_configure.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_envelope.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_file_tape_store_entry_ids.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_fork_store_merge_back.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_framework.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_hook_runtime.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_image_message.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_skills.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_subagent_tool.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_tape_search_output.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_types.py +0 -0
- {bub-0.4.1 → bub-0.4.2}/tests/test_utils.py +0 -0
{bub-0.4.1 → bub-0.4.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.4.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 4,
|
|
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
|
-
#
|
|
165
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
{bub-0.4.1 → bub-0.4.2}/LICENSE
RENAMED
|
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
|
|
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
|