bub 0.4.1__tar.gz → 0.4.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bub-0.4.1 → bub-0.4.3}/PKG-INFO +5 -3
- {bub-0.4.1 → bub-0.4.3}/README.md +2 -1
- {bub-0.4.1 → bub-0.4.3}/pyproject.toml +1 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/_version.py +2 -2
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/agent.py +4 -11
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/cli.py +7 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/hook_impl.py +56 -7
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/model_runner.py +35 -13
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/settings.py +5 -3
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/tools.py +17 -27
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/__init__.py +17 -7
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/renderer.py +19 -19
- bub-0.4.3/src/bub/channels/cli/writers.py +76 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/framework.py +10 -1
- {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/specs.py +12 -2
- bub-0.4.3/src/bub/store.py +537 -0
- {bub-0.4.1/src/bub/builtin → bub-0.4.3/src/bub}/tape.py +163 -10
- {bub-0.4.1 → bub-0.4.3}/src/bub/tools.py +32 -3
- {bub-0.4.1 → bub-0.4.3}/tests/test_agent_hooks.py +3 -3
- {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_agent.py +76 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_cli.py +45 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_hook_impl.py +80 -5
- {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_model_runner.py +78 -4
- {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_tools.py +42 -45
- {bub-0.4.1 → bub-0.4.3}/tests/test_channels.py +98 -14
- bub-0.4.3/tests/test_cli_renderer.py +50 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_file_tape_store_entry_ids.py +2 -2
- {bub-0.4.1 → bub-0.4.3}/tests/test_fork_store_merge_back.py +2 -2
- {bub-0.4.1 → bub-0.4.3}/tests/test_framework.py +30 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_settings.py +8 -1
- bub-0.4.1/tests/test_builtin_tape.py → bub-0.4.3/tests/test_tape.py +21 -3
- {bub-0.4.1 → bub-0.4.3}/tests/test_tools.py +64 -1
- bub-0.4.1/src/bub/builtin/store.py +0 -284
- bub-0.4.1/src/bub/channels/cli/writers.py +0 -42
- bub-0.4.1/src/bub/tape.py +0 -366
- bub-0.4.1/tests/test_cli_renderer.py +0 -32
- {bub-0.4.1 → bub-0.4.3}/.gitignore +0 -0
- {bub-0.4.1 → bub-0.4.3}/LICENSE +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/__main__.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/auth.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/codex_provider.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/context.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/shell_manager.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/builtin/steering.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/admission.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/base.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/ansi_bridge.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/cli/terminal_output.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/contracts.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/handler.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/manager.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/message.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/channels/telegram.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/configure.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/envelope.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/errors.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/__init__.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/interception.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/hooks/runtime.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/inquirer.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/model_selection.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/skills.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/streaming.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/turn.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/types.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/bub/utils.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/README.md +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/gh/SKILL.md +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/SKILL.md +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/scripts/telegram_rich.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/conftest.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_builtin_codex.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_cli_help.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_configure.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_envelope.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_hook_runtime.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_image_message.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_skills.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_subagent_tool.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_tape_search_output.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_types.py +0 -0
- {bub-0.4.1 → bub-0.4.3}/tests/test_utils.py +0 -0
{bub-0.4.1 → bub-0.4.3}/PKG-INFO
RENAMED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: A common shape for agents that live alongside people.
|
|
5
5
|
Project-URL: Homepage, https://bub.build
|
|
6
6
|
Project-URL: Repository, https://github.com/bubbuild/bub
|
|
7
7
|
Project-URL: Documentation, https://bub.build
|
|
8
8
|
Author-email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
|
|
9
|
+
License: Apache-2.0
|
|
9
10
|
License-File: LICENSE
|
|
10
11
|
Classifier: Intended Audience :: Developers
|
|
11
12
|
Classifier: Programming Language :: Python
|
|
@@ -153,7 +154,8 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
153
154
|
| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
|
|
154
155
|
| `BUB_API_BASE` | — | Custom provider endpoint |
|
|
155
156
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
156
|
-
| `
|
|
157
|
+
| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
|
|
158
|
+
| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
|
|
157
159
|
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
158
160
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
159
161
|
|
|
@@ -116,7 +116,8 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
116
116
|
| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
|
|
117
117
|
| `BUB_API_BASE` | — | Custom provider endpoint |
|
|
118
118
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
119
|
-
| `
|
|
119
|
+
| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
|
|
120
|
+
| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
|
|
120
121
|
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
121
122
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
122
123
|
|
|
@@ -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.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -22,21 +22,21 @@ from bub.builtin.model_runner import (
|
|
|
22
22
|
is_context_length_error,
|
|
23
23
|
)
|
|
24
24
|
from bub.builtin.settings import load_settings
|
|
25
|
-
from bub.builtin.tape import Tape
|
|
26
25
|
from bub.envelope import field_of
|
|
27
26
|
from bub.framework import BubFramework
|
|
28
27
|
from bub.skills import discover_skills, render_skills_prompt
|
|
28
|
+
from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore, is_async_tape_store
|
|
29
29
|
from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
|
|
30
|
-
from bub.tape import
|
|
30
|
+
from bub.tape import Tape
|
|
31
31
|
from bub.tools import (
|
|
32
32
|
REGISTRY,
|
|
33
33
|
Tool,
|
|
34
34
|
ToolContext,
|
|
35
|
+
model_tools,
|
|
35
36
|
)
|
|
36
37
|
from bub.turn import TurnState
|
|
37
38
|
from bub.utils import workspace_from_state
|
|
38
39
|
|
|
39
|
-
CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
40
40
|
HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
|
|
41
41
|
MAX_AUTO_HANDOFF_RETRIES = 1
|
|
42
42
|
|
|
@@ -290,7 +290,7 @@ class Agent:
|
|
|
290
290
|
)
|
|
291
291
|
return
|
|
292
292
|
|
|
293
|
-
next_prompt = self.
|
|
293
|
+
next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state)
|
|
294
294
|
await tape.append_event(
|
|
295
295
|
"loop.step",
|
|
296
296
|
{
|
|
@@ -352,8 +352,6 @@ class Agent:
|
|
|
352
352
|
allowed_skills: set[str] | None,
|
|
353
353
|
tools: list[Tool],
|
|
354
354
|
) -> AsyncStreamEvents:
|
|
355
|
-
from bub.builtin.tools import model_tools
|
|
356
|
-
|
|
357
355
|
system_prompt = self._system_prompt(
|
|
358
356
|
prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
|
|
359
357
|
)
|
|
@@ -399,11 +397,6 @@ class Agent:
|
|
|
399
397
|
blocks.append(skills_prompt)
|
|
400
398
|
return "\n\n".join(blocks)
|
|
401
399
|
|
|
402
|
-
def _continue_prompt(self, tape: Tape) -> str:
|
|
403
|
-
if "context" in tape.context.state:
|
|
404
|
-
return f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
|
|
405
|
-
return CONTINUE_PROMPT
|
|
406
|
-
|
|
407
400
|
def _has_steering_messages(self, state: TurnState) -> bool:
|
|
408
401
|
steering_inbox = self.framework.get_steering_inbox()
|
|
409
402
|
return bool(steering_inbox and steering_inbox.message_count(state) > 0)
|
|
@@ -87,6 +87,13 @@ def gateway(
|
|
|
87
87
|
framework = ctx.ensure_object(BubFramework)
|
|
88
88
|
|
|
89
89
|
manager = ChannelManager(framework, enabled_channels=enable_channels or None)
|
|
90
|
+
if not manager.enabled_channels():
|
|
91
|
+
typer.secho(
|
|
92
|
+
"No channels are enabled. Configure at least one channel with 'bub onboard' or '--enable-channel'.",
|
|
93
|
+
err=True,
|
|
94
|
+
fg="red",
|
|
95
|
+
)
|
|
96
|
+
raise typer.Exit(1)
|
|
90
97
|
asyncio.run(manager.listen_and_run())
|
|
91
98
|
|
|
92
99
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
from datetime import datetime
|
|
3
|
+
from difflib import get_close_matches
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import cast
|
|
5
6
|
|
|
@@ -18,9 +19,11 @@ from bub.channels.message import ChannelMessage, MediaItem
|
|
|
18
19
|
from bub.envelope import Envelope, content_of, field_of
|
|
19
20
|
from bub.framework import BubFramework
|
|
20
21
|
from bub.hooks import hookimpl
|
|
22
|
+
from bub.hooks.interception import ToolCall, ToolCallDecision
|
|
21
23
|
from bub.model_selection import ModelChoice, ModelOptions
|
|
22
|
-
from bub.
|
|
23
|
-
from bub.
|
|
24
|
+
from bub.store import TapeStore
|
|
25
|
+
from bub.streaming import AsyncStreamEvents, StreamState
|
|
26
|
+
from bub.tape import Tape, TapeContext
|
|
24
27
|
from bub.turn import TurnState
|
|
25
28
|
|
|
26
29
|
AGENTS_FILE_NAME = "AGENTS.md"
|
|
@@ -57,6 +60,7 @@ When responding to a channel message, you MUST:
|
|
|
57
60
|
Excessively long context may cause model call failures. In this case, you MAY use tape.info to retrieve the token usage and you SHOULD use tape.handoff tool to shorten the retrieved history.
|
|
58
61
|
</context_contract>
|
|
59
62
|
"""
|
|
63
|
+
DEFAULT_CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
60
64
|
|
|
61
65
|
|
|
62
66
|
class BuiltinImpl:
|
|
@@ -90,6 +94,16 @@ class BuiltinImpl:
|
|
|
90
94
|
return str(model) if model else None
|
|
91
95
|
return None
|
|
92
96
|
|
|
97
|
+
async def _recover_session_reasoning_effort(self, session_id: str) -> str | None:
|
|
98
|
+
"""Recover the latest per-session reasoning effort override."""
|
|
99
|
+
session = self._get_agent().tape.session_tape(session_id, self.framework.workspace)
|
|
100
|
+
entries = list(await session.store.fetch_all(session.query().kinds("event")))
|
|
101
|
+
for entry in reversed(entries):
|
|
102
|
+
if entry.kind == "event" and entry.payload.get("name") == "reasoning_effort_switch":
|
|
103
|
+
reasoning_effort = (entry.payload.get("data") or {}).get("reasoning_effort")
|
|
104
|
+
return str(reasoning_effort) if reasoning_effort else None
|
|
105
|
+
return None
|
|
106
|
+
|
|
93
107
|
@staticmethod
|
|
94
108
|
async def _discard_message(_: ChannelMessage) -> None:
|
|
95
109
|
return
|
|
@@ -149,6 +163,8 @@ class BuiltinImpl:
|
|
|
149
163
|
# fresh/unknown session never inherits another session's model.
|
|
150
164
|
if model := await self._recover_session_model(session_id):
|
|
151
165
|
state["model"] = model
|
|
166
|
+
if reasoning_effort := await self._recover_session_reasoning_effort(session_id):
|
|
167
|
+
state["reasoning_effort"] = reasoning_effort
|
|
152
168
|
if model := field_of(message, "context", {}).get("model"):
|
|
153
169
|
state["model"] = model
|
|
154
170
|
if thread_id := field_of(message, "context", {}).get("thread_id"):
|
|
@@ -161,9 +177,8 @@ class BuiltinImpl:
|
|
|
161
177
|
lifespan = field_of(message, "lifespan")
|
|
162
178
|
if lifespan is not None:
|
|
163
179
|
await lifespan.__aexit__(tp, value, traceback)
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
# turn), so nothing to write here — this hook only closes the lifespan.
|
|
180
|
+
# Per-session completion overrides are persisted by their tools as tape
|
|
181
|
+
# events, so nothing to write here — this hook only closes the lifespan.
|
|
167
182
|
|
|
168
183
|
@hookimpl
|
|
169
184
|
async def build_prompt(self, message: ChannelMessage, session_id: str, state: TurnState) -> str | list[dict]:
|
|
@@ -203,6 +218,13 @@ class BuiltinImpl:
|
|
|
203
218
|
model=state.get("model"),
|
|
204
219
|
)
|
|
205
220
|
|
|
221
|
+
@hookimpl
|
|
222
|
+
def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
|
|
223
|
+
del prompt, state
|
|
224
|
+
if "context" in tape.context.state:
|
|
225
|
+
return f"{DEFAULT_CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
|
|
226
|
+
return DEFAULT_CONTINUE_PROMPT
|
|
227
|
+
|
|
206
228
|
@hookimpl
|
|
207
229
|
def register_cli_commands(self, app: typer.Typer) -> None:
|
|
208
230
|
from bub.builtin import cli
|
|
@@ -248,7 +270,6 @@ class BuiltinImpl:
|
|
|
248
270
|
"Channels",
|
|
249
271
|
choices=available_channels,
|
|
250
272
|
enabled=default_channels,
|
|
251
|
-
validate=lambda values: True if values else "Select at least one channel.",
|
|
252
273
|
)
|
|
253
274
|
|
|
254
275
|
stream_output = bub_inquirer.ask_confirm("Stream output", default=bool(current_config.get("stream_output")))
|
|
@@ -345,7 +366,7 @@ class BuiltinImpl:
|
|
|
345
366
|
@hookimpl
|
|
346
367
|
def provide_tape_store(self) -> TapeStore:
|
|
347
368
|
import bub
|
|
348
|
-
from bub.
|
|
369
|
+
from bub.store import FileTapeStore
|
|
349
370
|
|
|
350
371
|
return FileTapeStore(directory=bub.home / "tapes")
|
|
351
372
|
|
|
@@ -368,3 +389,31 @@ class BuiltinImpl:
|
|
|
368
389
|
if channel_router is None:
|
|
369
390
|
return None
|
|
370
391
|
return await channel_router.admit_channel_message(session_id=session_id, message=message, turn=turn)
|
|
392
|
+
|
|
393
|
+
@hookimpl
|
|
394
|
+
async def before_tool_call(
|
|
395
|
+
self,
|
|
396
|
+
call: ToolCall,
|
|
397
|
+
state: TurnState,
|
|
398
|
+
) -> ToolCallDecision | None:
|
|
399
|
+
"""Recover hallucinated/unknown tool names without interrupting the turn.
|
|
400
|
+
|
|
401
|
+
When the model invokes a tool outside the current model-facing tool set,
|
|
402
|
+
replace it with a guidance ``tool_result`` so the model can re-issue a
|
|
403
|
+
valid call on the next step.
|
|
404
|
+
"""
|
|
405
|
+
from bub.tools import REGISTRY, model_tools
|
|
406
|
+
|
|
407
|
+
available_tools = tuple(tool_item.name for tool_item in model_tools(REGISTRY.values()))
|
|
408
|
+
if call.tool in available_tools:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
matches = get_close_matches(call.tool, available_tools, n=3, cutoff=0.6)
|
|
412
|
+
if matches:
|
|
413
|
+
suggestions = "\n".join(f"- {name}" for name in matches)
|
|
414
|
+
guidance = f"Tool `{call.tool}` does not exist. Did you mean one of the following?\n{suggestions}"
|
|
415
|
+
elif "skill" in available_tools:
|
|
416
|
+
guidance = f"Tool `{call.tool}` does not exist. Invoke the `skill` tool to list available skills."
|
|
417
|
+
else:
|
|
418
|
+
guidance = f"Tool `{call.tool}` does not exist. No similar tool is available."
|
|
419
|
+
return ToolCallDecision.replace(guidance)
|
|
@@ -28,7 +28,6 @@ from pydantic import TypeAdapter, ValidationError
|
|
|
28
28
|
|
|
29
29
|
from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider
|
|
30
30
|
from bub.builtin.settings import AgentSettings, ModelCandidate
|
|
31
|
-
from bub.builtin.tape import Tape
|
|
32
31
|
from bub.errors import BubError, ErrorKind
|
|
33
32
|
from bub.hooks.interception import (
|
|
34
33
|
AgentHooks,
|
|
@@ -37,6 +36,7 @@ from bub.hooks.interception import (
|
|
|
37
36
|
LlmCallResult,
|
|
38
37
|
)
|
|
39
38
|
from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
|
|
39
|
+
from bub.tape import Tape
|
|
40
40
|
from bub.tools import Tool, ToolContext, ToolExecutor
|
|
41
41
|
|
|
42
42
|
CONTEXT_LENGTH_PATTERNS = re.compile(
|
|
@@ -81,25 +81,33 @@ class ModelRunner:
|
|
|
81
81
|
return AnyLLM.create(candidate.provider, **client_kwargs)
|
|
82
82
|
|
|
83
83
|
async def completion_response(
|
|
84
|
-
self,
|
|
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
|
|
@@ -452,10 +461,23 @@ def tool_invocation_from_native(
|
|
|
452
461
|
tool_call: ChatCompletionMessageToolCall,
|
|
453
462
|
tool_map: dict[str, Tool],
|
|
454
463
|
) -> tuple[Tool, dict[str, Any]]:
|
|
464
|
+
"""Resolve a model tool call to (runtime tool, arguments).
|
|
465
|
+
|
|
466
|
+
An unknown tool name is not treated as a fatal error: it is surfaced as a
|
|
467
|
+
placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and
|
|
468
|
+
builtin hooks (e.g. ``before_tool_call``) can recover it into a guidance
|
|
469
|
+
``tool_result`` instead of interrupting the turn. If no hook replaces the
|
|
470
|
+
call, the placeholder raises a clear tool error rather than succeeding with
|
|
471
|
+
an empty result.
|
|
472
|
+
"""
|
|
455
473
|
tool_name, arguments = parse_native_function_call(tool_call)
|
|
456
474
|
tool_obj = tool_map.get(tool_name)
|
|
457
475
|
if tool_obj is None:
|
|
458
|
-
|
|
476
|
+
|
|
477
|
+
def raise_unknown_tool(**_: Any) -> None:
|
|
478
|
+
raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
|
|
479
|
+
|
|
480
|
+
return Tool(name=tool_name, handler=raise_unknown_tool), arguments
|
|
459
481
|
return tool_obj, arguments
|
|
460
482
|
|
|
461
483
|
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
import pathlib
|
|
5
5
|
import re
|
|
6
|
+
import sys
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
@@ -56,10 +57,11 @@ class AgentSettings(Settings):
|
|
|
56
57
|
fallback_models: list[str] | None = None
|
|
57
58
|
api_key: str | dict[str, str] | None = None
|
|
58
59
|
api_base: str | dict[str, str] | None = None
|
|
59
|
-
max_steps: int =
|
|
60
|
+
max_steps: int = Field(default=sys.maxsize, gt=0)
|
|
60
61
|
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
61
62
|
model_timeout_seconds: int | None = None
|
|
62
63
|
client_args: dict[str, Any] = Field(default_factory=dict)
|
|
64
|
+
completion_args: dict[str, Any] = Field(default_factory=dict)
|
|
63
65
|
verbose: int = Field(default=0, description="Verbosity level for logging. Higher means more verbose.", ge=0, le=2)
|
|
64
66
|
|
|
65
67
|
@classmethod
|
|
@@ -79,9 +81,9 @@ class AgentSettings(Settings):
|
|
|
79
81
|
file_secret_settings,
|
|
80
82
|
)
|
|
81
83
|
|
|
82
|
-
@field_validator("client_args", mode="before")
|
|
84
|
+
@field_validator("client_args", "completion_args", mode="before")
|
|
83
85
|
@classmethod
|
|
84
|
-
def
|
|
86
|
+
def default_dict_args(cls, value: Any) -> Any:
|
|
85
87
|
return {} if value is None else value
|
|
86
88
|
|
|
87
89
|
def model_candidates(self, model: str) -> list[ModelCandidate]:
|
|
@@ -4,11 +4,9 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import uuid
|
|
6
6
|
from collections.abc import Iterable
|
|
7
|
-
from dataclasses import replace
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
from typing import TYPE_CHECKING, cast
|
|
10
9
|
|
|
11
|
-
from openai.types.chat import ChatCompletionToolParam
|
|
12
10
|
from pydantic import BaseModel, Field
|
|
13
11
|
|
|
14
12
|
from bub.builtin.shell_manager import shell_manager
|
|
@@ -72,11 +70,6 @@ def resolve_tool_names(names: Iterable[str] | None = None, *, exclude: Iterable[
|
|
|
72
70
|
return resolved - excluded
|
|
73
71
|
|
|
74
72
|
|
|
75
|
-
def model_tools(tools: Iterable[Tool]) -> list[Tool]:
|
|
76
|
-
"""Convert runtime tool names into model-safe aliases."""
|
|
77
|
-
return [replace(tool_item, name=_to_model_name(tool_item.name)) for tool_item in tools]
|
|
78
|
-
|
|
79
|
-
|
|
80
73
|
def _tool_signature(tool_item: Tool) -> str:
|
|
81
74
|
properties = tool_item.parameters.get("properties", {})
|
|
82
75
|
if not isinstance(properties, dict) or not properties:
|
|
@@ -90,10 +83,11 @@ def _tool_signature(tool_item: Tool) -> str:
|
|
|
90
83
|
|
|
91
84
|
def render_tools_prompt(tools: Iterable[Tool]) -> str:
|
|
92
85
|
"""Render a human-readable description of tools for builtin agent prompts."""
|
|
93
|
-
|
|
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()
|
|
@@ -25,7 +25,6 @@ from rich.tree import Tree
|
|
|
25
25
|
|
|
26
26
|
import bub
|
|
27
27
|
from bub.builtin.agent import Agent
|
|
28
|
-
from bub.builtin.tape import TapeInfo
|
|
29
28
|
from bub.channels.admission import AdmitDecision, TurnSnapshot
|
|
30
29
|
from bub.channels.base import Interface
|
|
31
30
|
from bub.channels.cli.ansi_bridge import render_to_ansi
|
|
@@ -39,6 +38,7 @@ from bub.channels.contracts import MessageHandler
|
|
|
39
38
|
from bub.channels.message import ChannelMessage
|
|
40
39
|
from bub.envelope import Envelope, field_of
|
|
41
40
|
from bub.streaming import StreamEvent
|
|
41
|
+
from bub.tape import TapeInfo
|
|
42
42
|
from bub.tools import REGISTRY, tool_call_reporter
|
|
43
43
|
|
|
44
44
|
_GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment]
|
|
@@ -54,10 +54,12 @@ class _StreamPrinter:
|
|
|
54
54
|
expand_thinking: bool,
|
|
55
55
|
presenter: TerminalPresenter,
|
|
56
56
|
writer: MarkdownWriter | None = None,
|
|
57
|
+
print_end: Callable[[], None] | None = None,
|
|
57
58
|
invalidate: Callable[[], None] | None = None,
|
|
58
59
|
) -> None:
|
|
59
60
|
self._console = console
|
|
60
61
|
self._print_head = print_head
|
|
62
|
+
self._print_end = print_end or (lambda: None)
|
|
61
63
|
self._expand_thinking = expand_thinking
|
|
62
64
|
self._presenter = presenter
|
|
63
65
|
self._reasoning_chars = 0
|
|
@@ -82,13 +84,10 @@ class _StreamPrinter:
|
|
|
82
84
|
|
|
83
85
|
async def _record_reasoning(self, reasoning: str) -> None:
|
|
84
86
|
if not self._expand_thinking:
|
|
85
|
-
if self._reasoning_chars == 0:
|
|
86
|
-
await self._ensure_head()
|
|
87
87
|
self._reasoning_chars += len(reasoning)
|
|
88
88
|
self._invalidate()
|
|
89
89
|
return
|
|
90
90
|
|
|
91
|
-
await self._ensure_head()
|
|
92
91
|
if not self._reasoning_streaming:
|
|
93
92
|
await self._print(Text("[-] Thinking", style="dim"))
|
|
94
93
|
self._reasoning_streaming = True
|
|
@@ -97,19 +96,21 @@ class _StreamPrinter:
|
|
|
97
96
|
async def _print_content(self, content: str) -> bool:
|
|
98
97
|
if not (content.strip() or self.head_printed or self._reasoning_chars or self._reasoning_streaming):
|
|
99
98
|
return False
|
|
100
|
-
await self._ensure_head()
|
|
101
99
|
await self._close_reasoning_stream()
|
|
102
100
|
await self._flush_reasoning()
|
|
101
|
+
if content.strip() or self.head_printed:
|
|
102
|
+
await self._ensure_head()
|
|
103
103
|
await self._write_text(content)
|
|
104
104
|
return True
|
|
105
105
|
|
|
106
106
|
async def finish(self) -> None:
|
|
107
107
|
await self._close_reasoning_stream()
|
|
108
|
-
if self._reasoning_chars:
|
|
109
|
-
await self._ensure_head()
|
|
110
108
|
await self._flush_reasoning()
|
|
111
109
|
if self._writer.has_content():
|
|
110
|
+
await self._ensure_head()
|
|
112
111
|
await self._flush_text()
|
|
112
|
+
else:
|
|
113
|
+
await self._ensure_panel_end()
|
|
113
114
|
|
|
114
115
|
async def _print_stream_boundary(self) -> None:
|
|
115
116
|
await self.finish()
|
|
@@ -122,6 +123,12 @@ class _StreamPrinter:
|
|
|
122
123
|
await self._presenter.write(self._print_head)
|
|
123
124
|
self.head_printed = True
|
|
124
125
|
|
|
126
|
+
async def _ensure_panel_end(self) -> None:
|
|
127
|
+
if not self.head_printed:
|
|
128
|
+
return
|
|
129
|
+
await self._presenter.write(self._print_end)
|
|
130
|
+
self.head_printed = False
|
|
131
|
+
|
|
125
132
|
async def _close_reasoning_stream(self) -> None:
|
|
126
133
|
if not self._reasoning_streaming:
|
|
127
134
|
return
|
|
@@ -163,6 +170,8 @@ class _StreamPrinter:
|
|
|
163
170
|
def commit() -> None:
|
|
164
171
|
self._console.print(finished)
|
|
165
172
|
self._writer.clear()
|
|
173
|
+
self._print_end()
|
|
174
|
+
self.head_printed = False
|
|
166
175
|
|
|
167
176
|
await self._presenter.write(commit)
|
|
168
177
|
self._ansi_cache = None
|
|
@@ -353,6 +362,7 @@ class CliChannel(Interface):
|
|
|
353
362
|
printer = _StreamPrinter(
|
|
354
363
|
console=console,
|
|
355
364
|
print_head=lambda: self._renderer.print_head(message.kind),
|
|
365
|
+
print_end=lambda: self._renderer.print_end(message.kind),
|
|
356
366
|
expand_thinking=self._expand_thinking,
|
|
357
367
|
presenter=self._presenter,
|
|
358
368
|
invalidate=self._invalidate_prompt,
|
|
@@ -10,6 +10,7 @@ from rich.console import Console
|
|
|
10
10
|
from rich.panel import Panel
|
|
11
11
|
from rich.text import Text
|
|
12
12
|
|
|
13
|
+
from bub.channels.cli.writers import PanelEnd, PanelHead
|
|
13
14
|
from bub.channels.message import MessageKind
|
|
14
15
|
|
|
15
16
|
MAX_TOOL_PAYLOAD_CHARS = 4000
|
|
@@ -37,20 +38,10 @@ class CliRenderer:
|
|
|
37
38
|
return
|
|
38
39
|
self.console.print(Text(text, style="bright_black"))
|
|
39
40
|
|
|
40
|
-
def command_output(self, text: str) -> None:
|
|
41
|
-
if not text.strip():
|
|
42
|
-
return
|
|
43
|
-
self.console.print(f"[cyan bold]Command >[/]\n{text}")
|
|
44
|
-
|
|
45
|
-
def assistant_output(self, text: str) -> None:
|
|
46
|
-
if not text.strip():
|
|
47
|
-
return
|
|
48
|
-
self.console.print(f"[blue bold]Assistant >[/]\n{text}")
|
|
49
|
-
|
|
50
41
|
def error(self, text: str) -> None:
|
|
51
42
|
if not text.strip():
|
|
52
43
|
return
|
|
53
|
-
self.console.print(
|
|
44
|
+
self.console.print(Panel(text, title="error", border_style="red"))
|
|
54
45
|
|
|
55
46
|
def input_echo(self, prompt: str, text: str, steering: bool = False) -> None:
|
|
56
47
|
if not text.strip():
|
|
@@ -62,8 +53,7 @@ class CliRenderer:
|
|
|
62
53
|
self.console.print(Text(_format_tool_call(name, args, kwargs), style="magenta"), new_line_start=True)
|
|
63
54
|
|
|
64
55
|
def tool_call_success(self, *, name: str, result: Any, elapsed_ms: float) -> None:
|
|
65
|
-
|
|
66
|
-
self._tool_result(f"completed in {elapsed_ms:.0f} ms", rendered, style="green")
|
|
56
|
+
self._tool_status(f"completed in {elapsed_ms:.0f} ms", style="green")
|
|
67
57
|
|
|
68
58
|
def tool_call_error(self, *, name: str, error: BaseException, elapsed_ms: float) -> None:
|
|
69
59
|
rendered = _format_tool_payload({"type": error.__class__.__name__, "message": str(error)})
|
|
@@ -71,11 +61,19 @@ class CliRenderer:
|
|
|
71
61
|
|
|
72
62
|
def print_head(self, kind: MessageKind) -> None:
|
|
73
63
|
if kind == "command":
|
|
74
|
-
self.console.print("
|
|
64
|
+
self.console.print(PanelHead("Command", border_style="cyan"))
|
|
75
65
|
elif kind == "error":
|
|
76
|
-
self.console.print("
|
|
66
|
+
self.console.print(PanelHead("Error", border_style="red"))
|
|
77
67
|
else:
|
|
78
|
-
self.console.print("
|
|
68
|
+
self.console.print(PanelHead("Assistant", border_style="blue"))
|
|
69
|
+
|
|
70
|
+
def print_end(self, kind: MessageKind) -> None:
|
|
71
|
+
if kind == "command":
|
|
72
|
+
self.console.print(PanelEnd(border_style="cyan"))
|
|
73
|
+
elif kind == "error":
|
|
74
|
+
self.console.print(PanelEnd(border_style="red"))
|
|
75
|
+
else:
|
|
76
|
+
self.console.print(PanelEnd(border_style="blue"))
|
|
79
77
|
|
|
80
78
|
def log(self, message: object) -> None:
|
|
81
79
|
text = str(message).rstrip()
|
|
@@ -83,11 +81,13 @@ class CliRenderer:
|
|
|
83
81
|
self.console.print(text, new_line_start=True)
|
|
84
82
|
|
|
85
83
|
def _tool_result(self, label: str, rendered: str, *, style: str) -> None:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
for line in lines:
|
|
84
|
+
self._tool_status(label, style=style)
|
|
85
|
+
for line in rendered.splitlines() or [""]:
|
|
89
86
|
self.console.print(Text(f" {line}", style="bright_black"), highlight=False)
|
|
90
87
|
|
|
88
|
+
def _tool_status(self, label: str, *, style: str) -> None:
|
|
89
|
+
self.console.print(Text(f" ⎿ {label}", style=style), highlight=False)
|
|
90
|
+
|
|
91
91
|
|
|
92
92
|
def _format_tool_call(name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
93
93
|
params = _format_tool_params(args, kwargs)
|