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.
Files changed (72) hide show
  1. {bub-0.3.7 → bub-0.3.9}/.gitignore +2 -0
  2. {bub-0.3.7 → bub-0.3.9}/PKG-INFO +14 -10
  3. {bub-0.3.7 → bub-0.3.9}/README.md +13 -9
  4. {bub-0.3.7 → bub-0.3.9}/src/bub/__init__.py +14 -2
  5. {bub-0.3.7 → bub-0.3.9}/src/bub/__main__.py +9 -4
  6. {bub-0.3.7 → bub-0.3.9}/src/bub/_version.py +2 -2
  7. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/agent.py +12 -9
  8. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/cli.py +9 -2
  9. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/hook_impl.py +2 -2
  10. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/settings.py +1 -1
  11. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/shell_manager.py +16 -2
  12. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/store.py +12 -6
  13. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/tools.py +9 -3
  14. bub-0.3.9/src/bub/channels/__init__.py +5 -0
  15. {bub-0.3.7 → bub-0.3.9}/src/bub/channels/base.py +8 -0
  16. {bub-0.3.7 → bub-0.3.9}/src/bub/channels/cli/__init__.py +19 -22
  17. {bub-0.3.7 → bub-0.3.9}/src/bub/channels/cli/renderer.py +11 -36
  18. bub-0.3.9/src/bub/channels/manager.py +343 -0
  19. {bub-0.3.7 → bub-0.3.9}/src/bub/channels/telegram.py +2 -2
  20. {bub-0.3.7 → bub-0.3.9}/src/bub/configure.py +47 -0
  21. {bub-0.3.7 → bub-0.3.9}/src/bub/framework.py +53 -7
  22. {bub-0.3.7 → bub-0.3.9}/src/bub/hookspecs.py +14 -0
  23. {bub-0.3.7 → bub-0.3.9}/src/bub/skills.py +27 -3
  24. {bub-0.3.7 → bub-0.3.9}/src/bub/tools.py +12 -1
  25. bub-0.3.9/src/bub/turn_admission.py +105 -0
  26. {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/SKILL.md +2 -2
  27. bub-0.3.9/src/skills/telegram/SKILL.md +184 -0
  28. bub-0.3.9/src/skills/telegram/scripts/telegram_rich.py +224 -0
  29. {bub-0.3.7 → bub-0.3.9}/src/skills/telegram/scripts/telegram_send.py +30 -3
  30. {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_agent.py +56 -0
  31. {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_cli.py +71 -8
  32. {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_hook_impl.py +29 -0
  33. {bub-0.3.7 → bub-0.3.9}/tests/test_builtin_tools.py +68 -3
  34. bub-0.3.9/tests/test_channels.py +877 -0
  35. {bub-0.3.7 → bub-0.3.9}/tests/test_configure.py +56 -0
  36. {bub-0.3.7 → bub-0.3.9}/tests/test_framework.py +81 -0
  37. {bub-0.3.7 → bub-0.3.9}/tests/test_skills.py +53 -0
  38. {bub-0.3.7 → bub-0.3.9}/tests/test_tools.py +27 -3
  39. bub-0.3.7/src/bub/channels/__init__.py +0 -5
  40. bub-0.3.7/src/bub/channels/manager.py +0 -175
  41. bub-0.3.7/src/skills/telegram/SKILL.md +0 -113
  42. bub-0.3.7/tests/test_channels.py +0 -520
  43. {bub-0.3.7 → bub-0.3.9}/LICENSE +0 -0
  44. {bub-0.3.7 → bub-0.3.9}/pyproject.toml +0 -0
  45. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/__init__.py +0 -0
  46. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/auth.py +0 -0
  47. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/context.py +0 -0
  48. {bub-0.3.7 → bub-0.3.9}/src/bub/builtin/tape.py +0 -0
  49. {bub-0.3.7 → bub-0.3.9}/src/bub/channels/handler.py +0 -0
  50. {bub-0.3.7 → bub-0.3.9}/src/bub/channels/message.py +0 -0
  51. {bub-0.3.7 → bub-0.3.9}/src/bub/envelope.py +0 -0
  52. {bub-0.3.7 → bub-0.3.9}/src/bub/hook_runtime.py +0 -0
  53. {bub-0.3.7 → bub-0.3.9}/src/bub/inquirer.py +0 -0
  54. {bub-0.3.7 → bub-0.3.9}/src/bub/types.py +0 -0
  55. {bub-0.3.7 → bub-0.3.9}/src/bub/utils.py +0 -0
  56. {bub-0.3.7 → bub-0.3.9}/src/skills/README.md +0 -0
  57. {bub-0.3.7 → bub-0.3.9}/src/skills/gh/SKILL.md +0 -0
  58. {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/license.txt +0 -0
  59. {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  60. {bub-0.3.7 → bub-0.3.9}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  61. {bub-0.3.7 → bub-0.3.9}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  62. {bub-0.3.7 → bub-0.3.9}/tests/conftest.py +0 -0
  63. {bub-0.3.7 → bub-0.3.9}/tests/test_cli_help.py +0 -0
  64. {bub-0.3.7 → bub-0.3.9}/tests/test_envelope.py +0 -0
  65. {bub-0.3.7 → bub-0.3.9}/tests/test_file_tape_store_entry_ids.py +0 -0
  66. {bub-0.3.7 → bub-0.3.9}/tests/test_fork_store_merge_back.py +0 -0
  67. {bub-0.3.7 → bub-0.3.9}/tests/test_hook_runtime.py +0 -0
  68. {bub-0.3.7 → bub-0.3.9}/tests/test_image_message.py +0 -0
  69. {bub-0.3.7 → bub-0.3.9}/tests/test_settings.py +0 -0
  70. {bub-0.3.7 → bub-0.3.9}/tests/test_subagent_tool.py +0 -0
  71. {bub-0.3.7 → bub-0.3.9}/tests/test_tape_search_output.py +0 -0
  72. {bub-0.3.7 → bub-0.3.9}/tests/test_utils.py +0 -0
@@ -142,6 +142,8 @@ cython_debug/
142
142
 
143
143
  # Reference directory - ignore all reference projects
144
144
  reference/
145
+ !website/src/content/docs/**/build
146
+ !website/src/content/docs/**/reference
145
147
 
146
148
  # Local legacy backups created during framework migrations
147
149
  backup/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.3.7
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['content']}"
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:EchoPlugin"
127
+ echo = "my_package.plugin:echo_plugin"
124
128
  ```
125
129
 
126
- See the [Extending docs](https://bub.build/docs/extending/) for hook guides, packaging, and plugin structure.
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` | `1024` | Max tokens per model call |
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
- - [Architecture](https://bub.build/docs/concepts/architecture/) — the mental model behind the runtime
170
- - [Channels](https://bub.build/docs/guides/channels/) — run Bub in CLI, Telegram, or your own channel
171
- - [Skills](https://bub.build/docs/guides/skills/) — discover, inspect, and author Agent Skills in Bub
172
- - [Extending](https://bub.build/docs/extending/) — write plugins, override hooks, ship tools and skills
173
- - [Deployment](https://bub.build/docs/guides/deployment/) — Docker, environment, upgrades
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['content']}"
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:EchoPlugin"
91
+ echo = "my_package.plugin:echo_plugin"
88
92
  ```
89
93
 
90
- See the [Extending docs](https://bub.build/docs/extending/) for hook guides, packaging, and plugin structure.
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` | `1024` | Max tokens per model call |
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
- - [Architecture](https://bub.build/docs/concepts/architecture/) — the mental model behind the runtime
134
- - [Channels](https://bub.build/docs/guides/channels/) — run Bub in CLI, Telegram, or your own channel
135
- - [Skills](https://bub.build/docs/guides/skills/) — discover, inspect, and author Agent Skills in Bub
136
- - [Extending](https://bub.build/docs/extending/) — write plugins, override hooks, ship tools and skills
137
- - [Deployment](https://bub.build/docs/guides/deployment/) — Docker, environment, upgrades
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
- __all__ = ["BubFramework", "Settings", "config", "ensure_config", "home", "hookimpl", "tool"]
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.7'
22
- __version_tuple__ = version_tuple = (0, 3, 7)
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 = {name.casefold() for name in 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.casefold() in allowed_tools]
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(self, prompt: str, state: State, allowed_skills: set[str] | None = None) -> str:
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
- result = asyncio.run(framework.process_inbound(inbound))
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 UTC, datetime
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(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
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 = 1024
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(shell_id=f"bash-{uuid.uuid4().hex[:8]}", cmd=cmd, cwd=cwd, process=process)
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
- token = current_store.set(store)
105
- tape_token = current_fork_tape.set(tape)
106
- reset_token = current_tape_was_reset.set(False)
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.reset(token)
112
- current_fork_tape.reset(tape_token)
113
- current_tape_was_reset.reset(reset_token)
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
- shell = await shell_manager.start(cmd=cmd, cwd=target_cwd)
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
- """Quit the tasks of the current session."""
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
 
@@ -0,0 +1,5 @@
1
+ from .base import Channel, Interface, Lifecycle
2
+ from .manager import ChannelManager
3
+ from .message import ChannelMessage
4
+
5
+ __all__ = ["Channel", "ChannelManager", "ChannelMessage", "Interface", "Lifecycle"]
@@ -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 Channel
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(Channel):
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(0)
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
- live: Live | None = None
145
- text = ""
146
- try:
147
- async for event in stream:
148
- if event.kind == "text":
149
- content = str(event.data.get("delta", ""))
150
- if not content.strip() and not text:
151
- continue # skip leading whitespace-only events
152
- text += content
153
- if live is None:
154
- live = self._renderer.start_stream(message.kind, text)
155
- else:
156
- self._renderer.update_stream(live, kind=message.kind, text=text)
157
- yield event
158
- finally:
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(self.panel("command", text))
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(self.panel("normal", text))
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(self.panel("error", text))
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"