bub 0.3.7__tar.gz → 0.3.9__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.3.7 → bub-0.3.9}/.gitignore +2 -0
- {bub-0.3.7 → bub-0.3.9}/PKG-INFO +14 -10
- {bub-0.3.7 → bub-0.3.9}/README.md +13 -9
- {bub-0.3.7 → bub-0.3.9}/src/bub/__init__.py +14 -2
- {bub-0.3.7 → bub-0.3.9}/src/bub/__main__.py +9 -4
- {bub-0.3.7 → bub-0.3.9}/src/bub/_version.py +2 -2
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/agent.py +12 -9
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/cli.py +9 -2
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/hook_impl.py +2 -2
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/settings.py +1 -1
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/shell_manager.py +16 -2
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/store.py +12 -6
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/tools.py +9 -3
- bub-0.3.9/src/bub/channels/__init__.py +5 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/channels/base.py +8 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/channels/cli/__init__.py +19 -22
- {bub-0.3.7 → bub-0.3.9}/src/bub/channels/cli/renderer.py +11 -36
- bub-0.3.9/src/bub/channels/manager.py +343 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/channels/telegram.py +2 -2
- {bub-0.3.7 → bub-0.3.9}/src/bub/configure.py +47 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/framework.py +53 -7
- {bub-0.3.7 → bub-0.3.9}/src/bub/hookspecs.py +14 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/skills.py +27 -3
- {bub-0.3.7 → bub-0.3.9}/src/bub/tools.py +12 -1
- bub-0.3.9/src/bub/turn_admission.py +105 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/SKILL.md +2 -2
- bub-0.3.9/src/skills/telegram/SKILL.md +184 -0
- bub-0.3.9/src/skills/telegram/scripts/telegram_rich.py +224 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/telegram/scripts/telegram_send.py +30 -3
- {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_agent.py +56 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_cli.py +71 -8
- {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_hook_impl.py +29 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_tools.py +68 -3
- bub-0.3.9/tests/test_channels.py +877 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_configure.py +56 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_framework.py +81 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_skills.py +53 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_tools.py +27 -3
- bub-0.3.7/src/bub/channels/__init__.py +0 -5
- bub-0.3.7/src/bub/channels/manager.py +0 -175
- bub-0.3.7/src/skills/telegram/SKILL.md +0 -113
- bub-0.3.7/tests/test_channels.py +0 -520
- {bub-0.3.7 → bub-0.3.9}/LICENSE +0 -0
- {bub-0.3.7 → bub-0.3.9}/pyproject.toml +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/__init__.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/auth.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/context.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/tape.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/channels/handler.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/channels/message.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/envelope.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/hook_runtime.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/inquirer.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/types.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/bub/utils.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/README.md +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/gh/SKILL.md +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/conftest.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_cli_help.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_envelope.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_file_tape_store_entry_ids.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_fork_store_merge_back.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_hook_runtime.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_image_message.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_settings.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_subagent_tool.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_tape_search_output.py +0 -0
- {bub-0.3.7 → bub-0.3.9}/tests/test_utils.py +0 -0
{bub-0.3.7 → bub-0.3.9}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
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
|
|
@@ -106,24 +106,28 @@ Key source files:
|
|
|
106
106
|
|
|
107
107
|
```python
|
|
108
108
|
from bub import hookimpl
|
|
109
|
+
from bub.envelope import content_of
|
|
109
110
|
|
|
110
111
|
|
|
111
112
|
class EchoPlugin:
|
|
112
113
|
@hookimpl
|
|
113
114
|
def build_prompt(self, message, session_id, state):
|
|
114
|
-
return f"[echo] {message
|
|
115
|
+
return f"[echo] {content_of(message)}"
|
|
115
116
|
|
|
116
117
|
@hookimpl
|
|
117
118
|
async def run_model(self, prompt, session_id, state):
|
|
118
119
|
return prompt
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
echo_plugin = EchoPlugin()
|
|
119
123
|
```
|
|
120
124
|
|
|
121
125
|
```toml
|
|
122
126
|
[project.entry-points."bub"]
|
|
123
|
-
echo = "my_package.plugin:
|
|
127
|
+
echo = "my_package.plugin:echo_plugin"
|
|
124
128
|
```
|
|
125
129
|
|
|
126
|
-
See the [
|
|
130
|
+
See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, and plugin structure.
|
|
127
131
|
|
|
128
132
|
## CLI
|
|
129
133
|
|
|
@@ -150,7 +154,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
150
154
|
| `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
|
|
151
155
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
152
156
|
| `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
|
|
153
|
-
| `BUB_MAX_TOKENS` | `
|
|
157
|
+
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
154
158
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
155
159
|
|
|
156
160
|
## Background
|
|
@@ -166,11 +170,11 @@ Read more:
|
|
|
166
170
|
## Docs
|
|
167
171
|
|
|
168
172
|
- [Getting Started](https://bub.build/docs/getting-started/) — install Bub and run the first turn
|
|
169
|
-
- [
|
|
170
|
-
- [Channels](https://bub.build/docs/
|
|
171
|
-
- [Skills](https://bub.build/docs/
|
|
172
|
-
- [
|
|
173
|
-
- [Deployment](https://bub.build/docs/
|
|
173
|
+
- [Concepts](https://bub.build/docs/concepts/) — the mental model behind the runtime
|
|
174
|
+
- [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel
|
|
175
|
+
- [Skills](https://bub.build/docs/build/skills/) — discover, inspect, and author Agent Skills in Bub
|
|
176
|
+
- [Build](https://bub.build/docs/build/) — write plugins, override hooks, ship tools and skills
|
|
177
|
+
- [Deployment](https://bub.build/docs/operate/deploy/) — Docker, environment, upgrades
|
|
174
178
|
|
|
175
179
|
## Development
|
|
176
180
|
|
|
@@ -70,24 +70,28 @@ Key source files:
|
|
|
70
70
|
|
|
71
71
|
```python
|
|
72
72
|
from bub import hookimpl
|
|
73
|
+
from bub.envelope import content_of
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
class EchoPlugin:
|
|
76
77
|
@hookimpl
|
|
77
78
|
def build_prompt(self, message, session_id, state):
|
|
78
|
-
return f"[echo] {message
|
|
79
|
+
return f"[echo] {content_of(message)}"
|
|
79
80
|
|
|
80
81
|
@hookimpl
|
|
81
82
|
async def run_model(self, prompt, session_id, state):
|
|
82
83
|
return prompt
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
echo_plugin = EchoPlugin()
|
|
83
87
|
```
|
|
84
88
|
|
|
85
89
|
```toml
|
|
86
90
|
[project.entry-points."bub"]
|
|
87
|
-
echo = "my_package.plugin:
|
|
91
|
+
echo = "my_package.plugin:echo_plugin"
|
|
88
92
|
```
|
|
89
93
|
|
|
90
|
-
See the [
|
|
94
|
+
See the [Build docs](https://bub.build/docs/build/) for hook guides, packaging, and plugin structure.
|
|
91
95
|
|
|
92
96
|
## CLI
|
|
93
97
|
|
|
@@ -114,7 +118,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
114
118
|
| `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
|
|
115
119
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
116
120
|
| `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
|
|
117
|
-
| `BUB_MAX_TOKENS` | `
|
|
121
|
+
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
118
122
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
119
123
|
|
|
120
124
|
## Background
|
|
@@ -130,11 +134,11 @@ Read more:
|
|
|
130
134
|
## Docs
|
|
131
135
|
|
|
132
136
|
- [Getting Started](https://bub.build/docs/getting-started/) — install Bub and run the first turn
|
|
133
|
-
- [
|
|
134
|
-
- [Channels](https://bub.build/docs/
|
|
135
|
-
- [Skills](https://bub.build/docs/
|
|
136
|
-
- [
|
|
137
|
-
- [Deployment](https://bub.build/docs/
|
|
137
|
+
- [Concepts](https://bub.build/docs/concepts/) — the mental model behind the runtime
|
|
138
|
+
- [Channels](https://bub.build/docs/operate/channels/) — run Bub in CLI, Telegram, or your own channel
|
|
139
|
+
- [Skills](https://bub.build/docs/build/skills/) — discover, inspect, and author Agent Skills in Bub
|
|
140
|
+
- [Build](https://bub.build/docs/build/) — write plugins, override hooks, ship tools and skills
|
|
141
|
+
- [Deployment](https://bub.build/docs/operate/deploy/) — Docker, environment, upgrades
|
|
138
142
|
|
|
139
143
|
## Development
|
|
140
144
|
|
|
@@ -13,8 +13,20 @@ from bub.configure import Settings, config, ensure_config
|
|
|
13
13
|
from bub.framework import DEFAULT_HOME, BubFramework
|
|
14
14
|
from bub.hookspecs import hookimpl
|
|
15
15
|
from bub.tools import tool
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
from bub.turn_admission import AdmitDecision, SteeringBuffer, TurnSnapshot
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AdmitDecision",
|
|
20
|
+
"BubFramework",
|
|
21
|
+
"Settings",
|
|
22
|
+
"SteeringBuffer",
|
|
23
|
+
"TurnSnapshot",
|
|
24
|
+
"config",
|
|
25
|
+
"ensure_config",
|
|
26
|
+
"home",
|
|
27
|
+
"hookimpl",
|
|
28
|
+
"tool",
|
|
29
|
+
]
|
|
18
30
|
|
|
19
31
|
try:
|
|
20
32
|
__version__ = import_module("bub._version").version
|
|
@@ -2,22 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
6
|
+
|
|
5
7
|
import typer
|
|
6
8
|
|
|
7
9
|
from bub.framework import BubFramework
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
def _instrument_bub() -> None:
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
logger.remove()
|
|
16
|
+
logger.add(sys.stderr, colorize=True)
|
|
17
|
+
|
|
11
18
|
try:
|
|
12
19
|
import logfire
|
|
20
|
+
from logfire.integrations.loguru import LogfireHandler
|
|
13
21
|
|
|
14
22
|
logfire.configure()
|
|
23
|
+
logger.add(LogfireHandler(), format="{message}")
|
|
15
24
|
except ImportError:
|
|
16
25
|
pass
|
|
17
|
-
else:
|
|
18
|
-
from loguru import logger
|
|
19
|
-
|
|
20
|
-
logger.configure(handlers=[logfire.loguru_handler()])
|
|
21
26
|
|
|
22
27
|
|
|
23
28
|
def create_cli_app() -> typer.Typer:
|
|
@@ -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.3.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
21
|
+
__version__ = version = '0.3.9'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 9)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -24,6 +24,7 @@ from republic import (
|
|
|
24
24
|
StreamEvent,
|
|
25
25
|
StreamState,
|
|
26
26
|
TapeContext,
|
|
27
|
+
Tool,
|
|
27
28
|
ToolAutoResult,
|
|
28
29
|
ToolContext,
|
|
29
30
|
)
|
|
@@ -34,14 +35,14 @@ from bub.builtin.store import ForkTapeStore
|
|
|
34
35
|
from bub.builtin.tape import TapeService
|
|
35
36
|
from bub.framework import BubFramework
|
|
36
37
|
from bub.skills import discover_skills, render_skills_prompt
|
|
37
|
-
from bub.tools import REGISTRY, model_tools, render_tools_prompt
|
|
38
|
+
from bub.tools import REGISTRY, model_tools, render_tools_prompt, resolve_tool_names
|
|
38
39
|
from bub.types import State
|
|
39
40
|
from bub.utils import workspace_from_state
|
|
40
41
|
|
|
41
|
-
CONTINUE_PROMPT = "Continue the task."
|
|
42
|
+
CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
42
43
|
HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
|
|
43
44
|
_CONTEXT_LENGTH_PATTERNS = re.compile(
|
|
44
|
-
r"context.{0,20}length|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
|
|
45
|
+
r"context.{0,20}(?:length|window)|maximum.{0,20}context|token.{0,10}limit|prompt.{0,10}too long|tokens? > \d+ maximum",
|
|
45
46
|
re.IGNORECASE,
|
|
46
47
|
)
|
|
47
48
|
MAX_AUTO_HANDOFF_RETRIES = 1
|
|
@@ -530,12 +531,12 @@ class Agent:
|
|
|
530
531
|
) -> AsyncStreamEvents | ToolAutoResult:
|
|
531
532
|
prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt)
|
|
532
533
|
if allowed_tools is not None:
|
|
533
|
-
allowed_tools =
|
|
534
|
+
allowed_tools = resolve_tool_names(allowed_tools)
|
|
534
535
|
if allowed_skills is not None:
|
|
535
536
|
allowed_skills = {name.casefold() for name in allowed_skills}
|
|
536
537
|
tape.context.state["allowed_skills"] = list(allowed_skills)
|
|
537
538
|
if allowed_tools is not None:
|
|
538
|
-
tools = [tool for tool in REGISTRY.values() if tool.name
|
|
539
|
+
tools = [tool for tool in REGISTRY.values() if tool.name in allowed_tools]
|
|
539
540
|
else:
|
|
540
541
|
tools = list(REGISTRY.values())
|
|
541
542
|
async with asyncio.timeout(self.settings.model_timeout_seconds):
|
|
@@ -543,7 +544,7 @@ class Agent:
|
|
|
543
544
|
return await tape.stream_events_async(
|
|
544
545
|
prompt=prompt,
|
|
545
546
|
system_prompt=self._system_prompt(
|
|
546
|
-
prompt_text, state=tape.context.state, allowed_skills=allowed_skills
|
|
547
|
+
prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
|
|
547
548
|
),
|
|
548
549
|
max_tokens=self.settings.max_tokens,
|
|
549
550
|
tools=model_tools(tools),
|
|
@@ -553,18 +554,20 @@ class Agent:
|
|
|
553
554
|
return await tape.run_tools_async(
|
|
554
555
|
prompt=prompt,
|
|
555
556
|
system_prompt=self._system_prompt(
|
|
556
|
-
prompt_text, state=tape.context.state, allowed_skills=allowed_skills
|
|
557
|
+
prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
|
|
557
558
|
),
|
|
558
559
|
max_tokens=self.settings.max_tokens,
|
|
559
560
|
tools=model_tools(tools),
|
|
560
561
|
model=model,
|
|
561
562
|
)
|
|
562
563
|
|
|
563
|
-
def _system_prompt(
|
|
564
|
+
def _system_prompt(
|
|
565
|
+
self, prompt: str, state: State, allowed_skills: set[str] | None = None, tools: Iterable[Tool] | None = None
|
|
566
|
+
) -> str:
|
|
564
567
|
blocks: list[str] = []
|
|
565
568
|
if result := self.framework.get_system_prompt(prompt=prompt, state=state):
|
|
566
569
|
blocks.append(result)
|
|
567
|
-
tools_prompt = render_tools_prompt(REGISTRY.values())
|
|
570
|
+
tools_prompt = render_tools_prompt(tools if tools is not None else REGISTRY.values())
|
|
568
571
|
if tools_prompt:
|
|
569
572
|
blocks.append(tools_prompt)
|
|
570
573
|
workspace = workspace_from_state(state)
|
|
@@ -21,6 +21,7 @@ from bub.builtin.auth import app as login_app # noqa: F401
|
|
|
21
21
|
from bub.channels.message import ChannelMessage
|
|
22
22
|
from bub.envelope import field_of
|
|
23
23
|
from bub.framework import BubFramework
|
|
24
|
+
from bub.types import TurnResult
|
|
24
25
|
|
|
25
26
|
ONBOARD_BANNER = r"""
|
|
26
27
|
███████████ █████
|
|
@@ -53,7 +54,11 @@ def run(
|
|
|
53
54
|
context={"sender_id": sender_id},
|
|
54
55
|
)
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
async def _run() -> TurnResult:
|
|
58
|
+
async with framework.running():
|
|
59
|
+
return await framework.process_inbound(inbound)
|
|
60
|
+
|
|
61
|
+
result = asyncio.run(_run())
|
|
57
62
|
for outbound in result.outbounds:
|
|
58
63
|
rendered = str(field_of(outbound, "content", ""))
|
|
59
64
|
target_channel = str(field_of(outbound, "channel", "stdout"))
|
|
@@ -128,8 +133,10 @@ def _find_uv() -> str:
|
|
|
128
133
|
import shutil
|
|
129
134
|
import sysconfig
|
|
130
135
|
|
|
136
|
+
default_path = Path.home() / ".local" / "bin"
|
|
137
|
+
|
|
131
138
|
bin_path = sysconfig.get_path("scripts")
|
|
132
|
-
uv_path = shutil.which("uv", path=os.pathsep.join([bin_path, os.getenv("PATH", "")]))
|
|
139
|
+
uv_path = shutil.which("uv", path=os.pathsep.join([bin_path, str(default_path), os.getenv("PATH", "")]))
|
|
133
140
|
if uv_path is None:
|
|
134
141
|
raise FileNotFoundError("uv executable not found in PATH or scripts directory.")
|
|
135
142
|
return uv_path
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import sys
|
|
2
|
-
from datetime import
|
|
2
|
+
from datetime import datetime
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import cast
|
|
5
5
|
|
|
@@ -134,7 +134,7 @@ class BuiltinImpl:
|
|
|
134
134
|
message.kind = "command"
|
|
135
135
|
return content
|
|
136
136
|
context = field_of(message, "context_str")
|
|
137
|
-
now = datetime.now(
|
|
137
|
+
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
138
138
|
context_prefix = f"{context}\n---Date: {now}---\n" if context else ""
|
|
139
139
|
text = f"{context_prefix}{content}"
|
|
140
140
|
|
|
@@ -12,7 +12,7 @@ from pydantic_settings import SettingsConfigDict
|
|
|
12
12
|
from bub import Settings, config, ensure_config
|
|
13
13
|
|
|
14
14
|
DEFAULT_MODEL = "openrouter:openrouter/free"
|
|
15
|
-
DEFAULT_MAX_TOKENS =
|
|
15
|
+
DEFAULT_MAX_TOKENS = 16384
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def provider_specific(setting_name: str) -> Callable[[], dict[str, str] | None]:
|
|
@@ -13,6 +13,7 @@ class ManagedShell:
|
|
|
13
13
|
shell_id: str
|
|
14
14
|
cmd: str
|
|
15
15
|
cwd: str | None
|
|
16
|
+
session_id: str | None
|
|
16
17
|
process: asyncio.subprocess.Process
|
|
17
18
|
output_chunks: list[str] = field(default_factory=list)
|
|
18
19
|
read_tasks: list[asyncio.Task[None]] = field(default_factory=list)
|
|
@@ -36,7 +37,7 @@ class ShellManager:
|
|
|
36
37
|
def __init__(self) -> None:
|
|
37
38
|
self._shells: dict[str, ManagedShell] = {}
|
|
38
39
|
|
|
39
|
-
async def start(self, *, cmd: str, cwd: str | None) -> ManagedShell:
|
|
40
|
+
async def start(self, *, cmd: str, cwd: str | None, session_id: str | None = None) -> ManagedShell:
|
|
40
41
|
process = await asyncio.create_subprocess_shell(
|
|
41
42
|
cmd,
|
|
42
43
|
cwd=cwd,
|
|
@@ -44,7 +45,13 @@ class ShellManager:
|
|
|
44
45
|
stderr=asyncio.subprocess.PIPE,
|
|
45
46
|
executable=self.SHELL,
|
|
46
47
|
)
|
|
47
|
-
shell = ManagedShell(
|
|
48
|
+
shell = ManagedShell(
|
|
49
|
+
shell_id=f"bash-{uuid.uuid4().hex[:8]}",
|
|
50
|
+
cmd=cmd,
|
|
51
|
+
cwd=cwd,
|
|
52
|
+
session_id=session_id,
|
|
53
|
+
process=process,
|
|
54
|
+
)
|
|
48
55
|
shell.read_tasks.extend([
|
|
49
56
|
asyncio.create_task(self._drain_stream(shell, process.stdout)),
|
|
50
57
|
asyncio.create_task(self._drain_stream(shell, process.stderr)),
|
|
@@ -77,6 +84,13 @@ class ShellManager:
|
|
|
77
84
|
await self._finalize_shell(shell)
|
|
78
85
|
return shell
|
|
79
86
|
|
|
87
|
+
async def terminate_session(self, session_id: str) -> int:
|
|
88
|
+
shell_ids = [shell.shell_id for shell in self._shells.values() if shell.session_id == session_id]
|
|
89
|
+
for shell_id in shell_ids:
|
|
90
|
+
with contextlib.suppress(KeyError):
|
|
91
|
+
await self.terminate(shell_id)
|
|
92
|
+
return len(shell_ids)
|
|
93
|
+
|
|
80
94
|
async def wait_closed(self, shell_id: str) -> ManagedShell:
|
|
81
95
|
shell = self.get(shell_id)
|
|
82
96
|
if shell.returncode is None:
|
|
@@ -101,16 +101,22 @@ class ForkTapeStore:
|
|
|
101
101
|
@contextlib.asynccontextmanager
|
|
102
102
|
async def fork(self, tape: str, merge_back: bool = True) -> AsyncGenerator[None, None]:
|
|
103
103
|
store = InMemoryTapeStore()
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
# Save/restore instead of ContextVar.reset(token) to avoid
|
|
105
|
+
# "Token was created in a different Context" when cleanup
|
|
106
|
+
# runs in a different asyncio Task (e.g. cancellation, TaskGroup).
|
|
107
|
+
prev_store = current_store.get(_empty_store)
|
|
108
|
+
prev_fork_tape = current_fork_tape.get()
|
|
109
|
+
prev_was_reset = current_tape_was_reset.get()
|
|
110
|
+
current_store.set(store)
|
|
111
|
+
current_fork_tape.set(tape)
|
|
112
|
+
current_tape_was_reset.set(False)
|
|
107
113
|
try:
|
|
108
114
|
yield
|
|
109
115
|
finally:
|
|
110
116
|
was_reset = current_tape_was_reset.get()
|
|
111
|
-
current_store.
|
|
112
|
-
current_fork_tape.
|
|
113
|
-
current_tape_was_reset.
|
|
117
|
+
current_store.set(prev_store)
|
|
118
|
+
current_fork_tape.set(prev_fork_tape)
|
|
119
|
+
current_tape_was_reset.set(prev_was_reset)
|
|
114
120
|
if merge_back:
|
|
115
121
|
if was_reset:
|
|
116
122
|
await self._parent.reset(tape)
|
|
@@ -79,12 +79,17 @@ async def bash(
|
|
|
79
79
|
"""Run a shell command. Use background=true to keep it running and fetch output later via bash_output."""
|
|
80
80
|
workspace = context.state.get("_runtime_workspace")
|
|
81
81
|
target_cwd = cwd or workspace
|
|
82
|
-
|
|
82
|
+
raw_session_id = context.state.get("session_id")
|
|
83
|
+
session_id = str(raw_session_id) if raw_session_id is not None else None
|
|
84
|
+
shell = await shell_manager.start(cmd=cmd, cwd=target_cwd, session_id=session_id)
|
|
83
85
|
if background:
|
|
84
86
|
return f"started: {shell.shell_id}"
|
|
85
87
|
try:
|
|
86
88
|
async with asyncio.timeout(timeout_seconds):
|
|
87
89
|
shell = await shell_manager.wait_closed(shell.shell_id)
|
|
90
|
+
except asyncio.CancelledError:
|
|
91
|
+
await shell_manager.terminate(shell.shell_id)
|
|
92
|
+
raise
|
|
88
93
|
except TimeoutError:
|
|
89
94
|
await shell_manager.terminate(shell.shell_id)
|
|
90
95
|
return f"command timed out after {timeout_seconds} seconds and was terminated"
|
|
@@ -307,9 +312,10 @@ def show_help() -> str:
|
|
|
307
312
|
|
|
308
313
|
@tool(name="quit", context=True)
|
|
309
314
|
async def quit_tool(*, context: ToolContext) -> str:
|
|
310
|
-
"""
|
|
315
|
+
"""Abort the tasks of the current session. DO NOT use it in a normal workflow."""
|
|
311
316
|
agent = _get_agent(context)
|
|
312
|
-
session_id = context.state.get("session_id", "temp/unknown")
|
|
317
|
+
session_id = str(context.state.get("session_id", "temp/unknown"))
|
|
318
|
+
await shell_manager.terminate_session(session_id)
|
|
313
319
|
await agent.framework.quit_via_router(session_id)
|
|
314
320
|
return "Session tasks stopped."
|
|
315
321
|
|
|
@@ -39,3 +39,11 @@ class Channel(ABC):
|
|
|
39
39
|
def stream_events(self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]) -> AsyncIterable[StreamEvent]:
|
|
40
40
|
"""Optionally wrap the output stream for this channel."""
|
|
41
41
|
return stream
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Interface(Channel):
|
|
45
|
+
"""User-facing inbound/outbound surface managed by the channel runtime."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Lifecycle(Channel):
|
|
49
|
+
"""Background runtime managed alongside channels."""
|
|
@@ -14,12 +14,11 @@ from prompt_toolkit.key_binding import KeyBindings
|
|
|
14
14
|
from prompt_toolkit.patch_stdout import patch_stdout
|
|
15
15
|
from republic import StreamEvent
|
|
16
16
|
from rich import get_console
|
|
17
|
-
from rich.live import Live
|
|
18
17
|
|
|
19
18
|
import bub
|
|
20
19
|
from bub.builtin.agent import Agent
|
|
21
20
|
from bub.builtin.tape import TapeInfo
|
|
22
|
-
from bub.channels.base import
|
|
21
|
+
from bub.channels.base import Interface
|
|
23
22
|
from bub.channels.cli.renderer import CliRenderer
|
|
24
23
|
from bub.channels.message import ChannelMessage
|
|
25
24
|
from bub.envelope import field_of
|
|
@@ -27,7 +26,7 @@ from bub.tools import REGISTRY
|
|
|
27
26
|
from bub.types import MessageHandler
|
|
28
27
|
|
|
29
28
|
|
|
30
|
-
class CliChannel(
|
|
29
|
+
class CliChannel(Interface):
|
|
31
30
|
"""A simple CLI channel for testing and debugging."""
|
|
32
31
|
|
|
33
32
|
name = "cli"
|
|
@@ -44,14 +43,13 @@ class CliChannel(Channel):
|
|
|
44
43
|
self._mode = "agent" # or "shell"
|
|
45
44
|
self._main_task: asyncio.Task | None = None
|
|
46
45
|
self._renderer = CliRenderer(get_console())
|
|
47
|
-
self._log_handler_id = self._install_log_sink()
|
|
48
46
|
self._last_tape_info: TapeInfo | None = None
|
|
49
47
|
self._workspace = self._agent.framework.workspace
|
|
50
48
|
self._prompt = self._build_prompt(self._workspace)
|
|
51
49
|
|
|
52
50
|
def _install_log_sink(self) -> int:
|
|
53
51
|
with contextlib.suppress(ValueError):
|
|
54
|
-
logger.remove(
|
|
52
|
+
logger.remove()
|
|
55
53
|
return logger.add(self._renderer.log, colorize=False, format="{level:<8} | {message}")
|
|
56
54
|
|
|
57
55
|
async def _refresh_tape_info(self) -> None:
|
|
@@ -66,6 +64,7 @@ class CliChannel(Channel):
|
|
|
66
64
|
self._message_template["chat_id"] = chat_id
|
|
67
65
|
|
|
68
66
|
async def start(self, stop_event: asyncio.Event) -> None:
|
|
67
|
+
self._log_handler_id = self._install_log_sink()
|
|
69
68
|
self._stop_event = stop_event
|
|
70
69
|
self._main_task = asyncio.create_task(self._main_loop())
|
|
71
70
|
|
|
@@ -141,23 +140,21 @@ class CliChannel(Channel):
|
|
|
141
140
|
async def stream_events(
|
|
142
141
|
self, message: ChannelMessage, stream: AsyncIterable[StreamEvent]
|
|
143
142
|
) -> AsyncIterable[StreamEvent]:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if live is not None:
|
|
160
|
-
self._renderer.finish_stream(live, kind=message.kind, text=text)
|
|
143
|
+
head_printed = False
|
|
144
|
+
console = get_console()
|
|
145
|
+
async for event in stream:
|
|
146
|
+
if event.kind == "text":
|
|
147
|
+
content = str(event.data.get("delta", ""))
|
|
148
|
+
if not content.strip() and not head_printed:
|
|
149
|
+
continue # skip leading whitespace-only events
|
|
150
|
+
if not head_printed:
|
|
151
|
+
self._renderer.print_head(message.kind)
|
|
152
|
+
head_printed = True
|
|
153
|
+
|
|
154
|
+
console.print(content, end="", highlight=False)
|
|
155
|
+
elif event.kind == "final":
|
|
156
|
+
console.print("\n") # ensure we end with a newline after the final event
|
|
157
|
+
yield event
|
|
161
158
|
|
|
162
159
|
def _build_prompt(self, workspace: Path) -> PromptSession[str]:
|
|
163
160
|
kb = KeyBindings()
|
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
|
|
7
7
|
from rich.console import Console
|
|
8
|
-
from rich.live import Live
|
|
9
8
|
from rich.panel import Panel
|
|
10
9
|
from rich.text import Text
|
|
11
10
|
|
|
@@ -33,54 +32,30 @@ class CliRenderer:
|
|
|
33
32
|
return
|
|
34
33
|
self.console.print(Text(text, style="bright_black"))
|
|
35
34
|
|
|
36
|
-
def panel(self, kind: MessageKind, text: str) -> Panel:
|
|
37
|
-
title, border_style = self._panel_style(kind)
|
|
38
|
-
return Panel(text, title=title, border_style=border_style)
|
|
39
|
-
|
|
40
35
|
def command_output(self, text: str) -> None:
|
|
41
36
|
if not text.strip():
|
|
42
37
|
return
|
|
43
|
-
self.console.print(
|
|
38
|
+
self.console.print(f"[cyan bold]Command >[/]\n{text}")
|
|
44
39
|
|
|
45
40
|
def assistant_output(self, text: str) -> None:
|
|
46
41
|
if not text.strip():
|
|
47
42
|
return
|
|
48
|
-
self.console.print(
|
|
43
|
+
self.console.print(f"[blue bold]Assistant >[/]\n{text}")
|
|
49
44
|
|
|
50
45
|
def error(self, text: str) -> None:
|
|
51
46
|
if not text.strip():
|
|
52
47
|
return
|
|
53
|
-
self.console.print(
|
|
48
|
+
self.console.print(f"[red bold]Error >[/]\n{text}")
|
|
49
|
+
|
|
50
|
+
def print_head(self, kind: MessageKind) -> None:
|
|
51
|
+
if kind == "command":
|
|
52
|
+
self.console.print("[cyan bold]Command >[/]")
|
|
53
|
+
elif kind == "error":
|
|
54
|
+
self.console.print("[red bold]Error >[/]")
|
|
55
|
+
else:
|
|
56
|
+
self.console.print("[blue bold]Assistant >[/]")
|
|
54
57
|
|
|
55
58
|
def log(self, message: object) -> None:
|
|
56
59
|
text = str(message).rstrip()
|
|
57
60
|
if text:
|
|
58
61
|
self.console.print(text)
|
|
59
|
-
|
|
60
|
-
def start_stream(self, kind: MessageKind, text: str) -> Live:
|
|
61
|
-
live = Live(
|
|
62
|
-
self.panel(kind, text),
|
|
63
|
-
console=self.console,
|
|
64
|
-
auto_refresh=False,
|
|
65
|
-
transient=False,
|
|
66
|
-
vertical_overflow="visible",
|
|
67
|
-
)
|
|
68
|
-
live.start(refresh=True)
|
|
69
|
-
return live
|
|
70
|
-
|
|
71
|
-
def update_stream(self, live: Live, *, kind: MessageKind, text: str) -> None:
|
|
72
|
-
live.update(self.panel(kind, text), refresh=True)
|
|
73
|
-
|
|
74
|
-
def finish_stream(self, live: Live, *, kind: MessageKind, text: str) -> None:
|
|
75
|
-
live.update(self.panel(kind, text), refresh=True)
|
|
76
|
-
live.stop()
|
|
77
|
-
|
|
78
|
-
@staticmethod
|
|
79
|
-
def _panel_style(kind: MessageKind) -> tuple[str, str]:
|
|
80
|
-
match kind:
|
|
81
|
-
case "error":
|
|
82
|
-
return "Error", "red"
|
|
83
|
-
case "command":
|
|
84
|
-
return "Command", "green"
|
|
85
|
-
case _:
|
|
86
|
-
return "Assistant", "blue"
|