bub 0.3.4__tar.gz → 0.3.6__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 (64) hide show
  1. {bub-0.3.4 → bub-0.3.6}/.gitignore +1 -0
  2. {bub-0.3.4 → bub-0.3.6}/PKG-INFO +5 -2
  3. {bub-0.3.4 → bub-0.3.6}/README.md +4 -1
  4. {bub-0.3.4 → bub-0.3.6}/pyproject.toml +9 -2
  5. bub-0.3.6/src/bub/__init__.py +19 -0
  6. bub-0.3.6/src/bub/_version.py +24 -0
  7. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/agent.py +252 -25
  8. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/cli.py +61 -6
  9. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/hook_impl.py +6 -3
  10. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/shell_manager.py +4 -1
  11. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/tools.py +3 -3
  12. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/base.py +4 -4
  13. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/cli/__init__.py +27 -33
  14. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/manager.py +14 -7
  15. {bub-0.3.4 → bub-0.3.6}/src/bub/framework.py +27 -11
  16. {bub-0.3.4 → bub-0.3.6}/src/bub/hook_runtime.py +16 -1
  17. {bub-0.3.4 → bub-0.3.6}/src/bub/types.py +2 -2
  18. {bub-0.3.4 → bub-0.3.6}/src/skills/telegram/SKILL.md +17 -29
  19. {bub-0.3.4 → bub-0.3.6}/src/skills/telegram/scripts/telegram_edit.py +2 -1
  20. {bub-0.3.4 → bub-0.3.6}/src/skills/telegram/scripts/telegram_send.py +1 -1
  21. {bub-0.3.4 → bub-0.3.6}/tests/test_builtin_agent.py +5 -5
  22. bub-0.3.6/tests/test_builtin_cli.py +150 -0
  23. {bub-0.3.4 → bub-0.3.6}/tests/test_builtin_hook_impl.py +22 -4
  24. {bub-0.3.4 → bub-0.3.6}/tests/test_channels.py +81 -5
  25. {bub-0.3.4 → bub-0.3.6}/tests/test_framework.py +112 -0
  26. {bub-0.3.4 → bub-0.3.6}/tests/test_hook_runtime.py +35 -0
  27. {bub-0.3.4 → bub-0.3.6}/tests/test_subagent_tool.py +12 -12
  28. bub-0.3.4/src/bub/__init__.py +0 -8
  29. bub-0.3.4/tests/test_builtin_cli.py +0 -71
  30. {bub-0.3.4 → bub-0.3.6}/LICENSE +0 -0
  31. {bub-0.3.4 → bub-0.3.6}/src/bub/__main__.py +0 -0
  32. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/__init__.py +0 -0
  33. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/auth.py +0 -0
  34. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/context.py +0 -0
  35. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/settings.py +0 -0
  36. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/store.py +0 -0
  37. {bub-0.3.4 → bub-0.3.6}/src/bub/builtin/tape.py +0 -0
  38. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/__init__.py +0 -0
  39. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/cli/renderer.py +0 -0
  40. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/handler.py +0 -0
  41. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/message.py +0 -0
  42. {bub-0.3.4 → bub-0.3.6}/src/bub/channels/telegram.py +0 -0
  43. {bub-0.3.4 → bub-0.3.6}/src/bub/envelope.py +0 -0
  44. {bub-0.3.4 → bub-0.3.6}/src/bub/hookspecs.py +0 -0
  45. {bub-0.3.4 → bub-0.3.6}/src/bub/skills.py +0 -0
  46. {bub-0.3.4 → bub-0.3.6}/src/bub/tools.py +0 -0
  47. {bub-0.3.4 → bub-0.3.6}/src/bub/utils.py +0 -0
  48. {bub-0.3.4 → bub-0.3.6}/src/skills/README.md +0 -0
  49. {bub-0.3.4 → bub-0.3.6}/src/skills/gh/SKILL.md +0 -0
  50. {bub-0.3.4 → bub-0.3.6}/src/skills/skill-creator/SKILL.md +0 -0
  51. {bub-0.3.4 → bub-0.3.6}/src/skills/skill-creator/license.txt +0 -0
  52. {bub-0.3.4 → bub-0.3.6}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  53. {bub-0.3.4 → bub-0.3.6}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  54. {bub-0.3.4 → bub-0.3.6}/tests/test_builtin_tools.py +0 -0
  55. {bub-0.3.4 → bub-0.3.6}/tests/test_cli_help.py +0 -0
  56. {bub-0.3.4 → bub-0.3.6}/tests/test_envelope.py +0 -0
  57. {bub-0.3.4 → bub-0.3.6}/tests/test_file_tape_store_entry_ids.py +0 -0
  58. {bub-0.3.4 → bub-0.3.6}/tests/test_fork_store_merge_back.py +0 -0
  59. {bub-0.3.4 → bub-0.3.6}/tests/test_image_message.py +0 -0
  60. {bub-0.3.4 → bub-0.3.6}/tests/test_settings.py +0 -0
  61. {bub-0.3.4 → bub-0.3.6}/tests/test_skills.py +0 -0
  62. {bub-0.3.4 → bub-0.3.6}/tests/test_tape_search_output.py +0 -0
  63. {bub-0.3.4 → bub-0.3.6}/tests/test_tools.py +0 -0
  64. {bub-0.3.4 → bub-0.3.6}/tests/test_utils.py +0 -0
@@ -145,3 +145,4 @@ reference/
145
145
 
146
146
  # Local legacy backups created during framework migrations
147
147
  backup/
148
+ _version.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.3.4
3
+ Version: 0.3.6
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
@@ -130,11 +130,14 @@ See the [Extension Guide](https://bub.build/extension-guide/) for hook semantics
130
130
  | `bub chat` | Interactive REPL |
131
131
  | `bub run MESSAGE` | One-shot turn |
132
132
  | `bub gateway` | Channel listener (Telegram, etc.) |
133
+ | `bub install` | Install or sync Bub plugin deps |
134
+ | `bub update` | Upgrade Bub plugin deps |
133
135
  | `bub login openai` | OpenAI Codex OAuth |
134
- | `bub hooks` | Print hook-to-plugin bindings |
135
136
 
136
137
  Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`).
137
138
 
139
+ `bub hooks` still exists for diagnostics, but it is hidden from top-level help. `bub install` and `bub update` manage a separate uv project for Bub plugins, defaulting to `~/.bub/bub-project` or `BUB_PROJECT`.
140
+
138
141
  ## Configuration
139
142
 
140
143
  | Variable | Default | Description |
@@ -96,11 +96,14 @@ See the [Extension Guide](https://bub.build/extension-guide/) for hook semantics
96
96
  | `bub chat` | Interactive REPL |
97
97
  | `bub run MESSAGE` | One-shot turn |
98
98
  | `bub gateway` | Channel listener (Telegram, etc.) |
99
+ | `bub install` | Install or sync Bub plugin deps |
100
+ | `bub update` | Upgrade Bub plugin deps |
99
101
  | `bub login openai` | OpenAI Codex OAuth |
100
- | `bub hooks` | Print hook-to-plugin bindings |
101
102
 
102
103
  Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-skill`, `,fs.read path=README.md`).
103
104
 
105
+ `bub hooks` still exists for diagnostics, but it is hidden from top-level help. `bub install` and `bub update` manage a separate uv project for Bub plugins, defaulting to `~/.bub/bub-project` or `BUB_PROJECT`.
106
+
104
107
  ## Configuration
105
108
 
106
109
  | Variable | Default | Description |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bub"
3
- version = "0.3.4"
3
+ dynamic = ["version"]
4
4
  description = "A common shape for agents that live alongside people."
5
5
  authors = [
6
6
  { name = "Chojan Shang", email = "psiace@apache.org" },
@@ -64,9 +64,16 @@ dev = [
64
64
  ]
65
65
 
66
66
  [build-system]
67
- requires = ["hatchling"]
67
+ requires = ["hatchling", "hatch-vcs"]
68
68
  build-backend = "hatchling.build"
69
69
 
70
+ [tool.hatch.version]
71
+ source = "vcs"
72
+ fallback-version = "0.3.0"
73
+
74
+ [tool.hatch.build.hooks.vcs]
75
+ version-file = "src/bub/_version.py"
76
+
70
77
  [tool.hatch.build.targets.sdist]
71
78
  only-include = ["src/bub", "src/skills", "tests"]
72
79
 
@@ -0,0 +1,19 @@
1
+ """Bub framework package."""
2
+
3
+ from importlib import import_module
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version as metadata_version
6
+
7
+ from bub.framework import BubFramework
8
+ from bub.hookspecs import hookimpl
9
+ from bub.tools import tool
10
+
11
+ __all__ = ["BubFramework", "hookimpl", "tool"]
12
+
13
+ try:
14
+ __version__ = import_module("bub._version").version
15
+ except ModuleNotFoundError:
16
+ try:
17
+ __version__ = metadata_version("bub")
18
+ except PackageNotFoundError:
19
+ __version__ = "0.0.0"
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.3.6'
22
+ __version_tuple__ = version_tuple = (0, 3, 6)
23
+
24
+ __commit_id__ = commit_id = None
@@ -13,16 +13,18 @@ from dataclasses import dataclass, replace
13
13
  from datetime import UTC, datetime
14
14
  from functools import cached_property
15
15
  from pathlib import Path
16
- from typing import Any
16
+ from typing import Any, Literal, overload
17
17
 
18
18
  from loguru import logger
19
19
  from republic import (
20
20
  LLM,
21
21
  AsyncStreamEvents,
22
22
  AsyncTapeStore,
23
+ RepublicError,
23
24
  StreamEvent,
24
25
  StreamState,
25
26
  TapeContext,
27
+ ToolAutoResult,
26
28
  ToolContext,
27
29
  )
28
30
  from republic.tape import InMemoryTapeStore, Tape
@@ -89,6 +91,29 @@ class Agent:
89
91
  model: str | None = None,
90
92
  allowed_skills: Collection[str] | None = None,
91
93
  allowed_tools: Collection[str] | None = None,
94
+ ) -> str:
95
+ if not prompt:
96
+ return "error: empty prompt"
97
+ tape = self.tapes.session_tape(session_id, workspace_from_state(state))
98
+ tape.context = replace(tape.context, state=state)
99
+ merge_back = not session_id.startswith("temp/")
100
+ async with self.tapes.fork_tape(tape.name, merge_back=merge_back):
101
+ await self.tapes.ensure_bootstrap_anchor(tape.name)
102
+ if isinstance(prompt, str) and prompt.strip().startswith(","):
103
+ return await self._run_command(tape=tape, line=prompt.strip())
104
+ return await self._agent_loop(
105
+ tape=tape, prompt=prompt, model=model, allowed_skills=allowed_skills, allowed_tools=allowed_tools
106
+ )
107
+
108
+ async def run_stream(
109
+ self,
110
+ *,
111
+ session_id: str,
112
+ prompt: str | list[dict],
113
+ state: State,
114
+ model: str | None = None,
115
+ allowed_skills: Collection[str] | None = None,
116
+ allowed_tools: Collection[str] | None = None,
92
117
  ) -> AsyncStreamEvents:
93
118
  if not prompt:
94
119
  events = [
@@ -113,7 +138,12 @@ class Agent:
113
138
  ])
114
139
  else:
115
140
  events = await self._agent_loop(
116
- tape=tape, prompt=prompt, model=model, allowed_skills=allowed_skills, allowed_tools=allowed_tools
141
+ tape=tape,
142
+ prompt=prompt,
143
+ model=model,
144
+ allowed_skills=allowed_skills,
145
+ allowed_tools=allowed_tools,
146
+ stream_output=True,
117
147
  )
118
148
  return self._events_with_callback(events, callback=stack.aclose)
119
149
 
@@ -157,6 +187,30 @@ class Agent:
157
187
  }
158
188
  await self.tapes.append_event(tape.name, "command", event_payload)
159
189
 
190
+ @overload
191
+ async def _agent_loop(
192
+ self,
193
+ *,
194
+ tape: Tape,
195
+ prompt: str | list[dict],
196
+ model: str | None = ...,
197
+ allowed_skills: Collection[str] | None = ...,
198
+ allowed_tools: Collection[str] | None = ...,
199
+ stream_output: Literal[False] = ...,
200
+ ) -> str: ...
201
+
202
+ @overload
203
+ async def _agent_loop(
204
+ self,
205
+ *,
206
+ tape: Tape,
207
+ prompt: str | list[dict],
208
+ model: str | None = ...,
209
+ allowed_skills: Collection[str] | None = ...,
210
+ allowed_tools: Collection[str] | None = ...,
211
+ stream_output: Literal[True] = ...,
212
+ ) -> AsyncStreamEvents: ...
213
+
160
214
  async def _agent_loop(
161
215
  self,
162
216
  *,
@@ -165,7 +219,8 @@ class Agent:
165
219
  model: str | None = None,
166
220
  allowed_skills: Collection[str] | None = None,
167
221
  allowed_tools: Collection[str] | None = None,
168
- ) -> AsyncStreamEvents:
222
+ stream_output: bool = False,
223
+ ) -> AsyncStreamEvents | str:
169
224
  next_prompt: str | list[dict] = prompt
170
225
  display_model = model or self.settings.model
171
226
  await self.tapes.append_event(
@@ -178,16 +233,137 @@ class Agent:
178
233
  "allowed_tools": list(allowed_tools) if allowed_tools else None,
179
234
  },
180
235
  )
181
- state = StreamState()
182
- iterator = self._stream_events_with_auto_handoff(
183
- tape=tape,
184
- prompt=next_prompt,
185
- state=state,
186
- model=model,
187
- allowed_skills=allowed_skills,
188
- allowed_tools=allowed_tools,
189
- )
190
- return AsyncStreamEvents(iterator, state=state)
236
+ if stream_output:
237
+ state = StreamState()
238
+ iterator = self._stream_events_with_auto_handoff(
239
+ tape=tape,
240
+ prompt=next_prompt,
241
+ state=state,
242
+ model=model,
243
+ allowed_skills=allowed_skills,
244
+ allowed_tools=allowed_tools,
245
+ )
246
+ return AsyncStreamEvents(iterator, state=state)
247
+ else:
248
+ return await self._run_tools_with_auto_handoff(
249
+ tape=tape,
250
+ prompt=next_prompt,
251
+ model=model,
252
+ allowed_skills=allowed_skills,
253
+ allowed_tools=allowed_tools,
254
+ )
255
+
256
+ async def _run_tools_with_auto_handoff(
257
+ self,
258
+ tape: Tape,
259
+ prompt: str | list[dict],
260
+ model: str | None = None,
261
+ allowed_skills: Collection[str] | None = None,
262
+ allowed_tools: Collection[str] | None = None,
263
+ ) -> str:
264
+ auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES
265
+ display_model = model or self.settings.model
266
+ next_prompt = prompt
267
+ for step in range(1, self.settings.max_steps + 1):
268
+ start = time.monotonic()
269
+ logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model)
270
+ await self.tapes.append_event(tape.name, "loop.step.start", {"step": step, "prompt": next_prompt})
271
+ try:
272
+ output = await self._run_once(
273
+ tape=tape,
274
+ prompt=next_prompt,
275
+ model=model,
276
+ allowed_skills=allowed_skills,
277
+ allowed_tools=allowed_tools,
278
+ )
279
+ except Exception as exc:
280
+ elapsed_ms = int((time.monotonic() - start) * 1000)
281
+ await self.tapes.append_event(
282
+ tape.name,
283
+ "loop.step",
284
+ {
285
+ "step": step,
286
+ "elapsed_ms": elapsed_ms,
287
+ "status": "error",
288
+ "error": f"{exc!s}",
289
+ "date": datetime.now(UTC).isoformat(),
290
+ },
291
+ )
292
+ raise
293
+
294
+ outcome = _resolve_tool_auto_result(output)
295
+ elapsed_ms = int((time.monotonic() - start) * 1000)
296
+ if outcome.kind == "text":
297
+ await self.tapes.append_event(
298
+ tape.name,
299
+ "loop.step",
300
+ {
301
+ "step": step,
302
+ "elapsed_ms": elapsed_ms,
303
+ "status": "ok",
304
+ "date": datetime.now(UTC).isoformat(),
305
+ },
306
+ )
307
+ return outcome.text
308
+ if outcome.kind == "continue":
309
+ if "context" in tape.context.state:
310
+ next_prompt = f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
311
+ else:
312
+ next_prompt = CONTINUE_PROMPT
313
+ await self.tapes.append_event(
314
+ tape.name,
315
+ "loop.step",
316
+ {
317
+ "step": step,
318
+ "elapsed_ms": elapsed_ms,
319
+ "status": "continue",
320
+ "date": datetime.now(UTC).isoformat(),
321
+ },
322
+ )
323
+ continue
324
+
325
+ # Check if this is a context-length error that can be recovered via auto-handoff
326
+ if auto_handoff_remaining > 0 and _is_context_length_error(outcome.error):
327
+ auto_handoff_remaining -= 1
328
+ logger.warning(
329
+ "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}",
330
+ tape.name,
331
+ step,
332
+ )
333
+ await self.tapes.handoff(
334
+ tape.name,
335
+ name="auto_handoff/context_overflow",
336
+ state={"reason": "context_length_exceeded", "error": outcome.error},
337
+ )
338
+ await self.tapes.append_event(
339
+ tape.name,
340
+ "loop.step",
341
+ {
342
+ "step": step,
343
+ "elapsed_ms": elapsed_ms,
344
+ "status": "auto_handoff",
345
+ "error": outcome.error,
346
+ "date": datetime.now(UTC).isoformat(),
347
+ },
348
+ )
349
+ # Retry with original prompt — the handoff anchor will truncate history
350
+ next_prompt = prompt
351
+ continue
352
+
353
+ await self.tapes.append_event(
354
+ tape.name,
355
+ "loop.step",
356
+ {
357
+ "step": step,
358
+ "elapsed_ms": elapsed_ms,
359
+ "status": "error",
360
+ "error": outcome.error,
361
+ "date": datetime.now(UTC).isoformat(),
362
+ },
363
+ )
364
+ raise RuntimeError(outcome.error)
365
+
366
+ raise RuntimeError(f"max_steps_reached={self.settings.max_steps}")
191
367
 
192
368
  async def _stream_events_with_auto_handoff(
193
369
  self,
@@ -212,6 +388,7 @@ class Agent:
212
388
  model=model,
213
389
  allowed_skills=allowed_skills,
214
390
  allowed_tools=allowed_tools,
391
+ stream_output=True,
215
392
  )
216
393
  async for event in output:
217
394
  yield event
@@ -229,7 +406,7 @@ class Agent:
229
406
  },
230
407
  )
231
408
  elif event.kind == "final":
232
- outcome = _resolve_tool_auto_result(event.data)
409
+ outcome = _resolve_final_data(event.data, output.error)
233
410
 
234
411
  state.error = output.error
235
412
  state.usage = output.usage
@@ -315,6 +492,30 @@ class Agent:
315
492
  expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys())
316
493
  return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills)
317
494
 
495
+ @overload
496
+ async def _run_once(
497
+ self,
498
+ *,
499
+ tape: Tape,
500
+ prompt: str | list[dict],
501
+ model: str | None = ...,
502
+ allowed_skills: Collection[str] | None = ...,
503
+ allowed_tools: Collection[str] | None = ...,
504
+ stream_output: Literal[False] = ...,
505
+ ) -> ToolAutoResult: ...
506
+
507
+ @overload
508
+ async def _run_once(
509
+ self,
510
+ *,
511
+ tape: Tape,
512
+ prompt: str | list[dict],
513
+ model: str | None = ...,
514
+ allowed_skills: Collection[str] | None = ...,
515
+ allowed_tools: Collection[str] | None = ...,
516
+ stream_output: Literal[True] = ...,
517
+ ) -> AsyncStreamEvents: ...
518
+
318
519
  async def _run_once(
319
520
  self,
320
521
  *,
@@ -323,7 +524,8 @@ class Agent:
323
524
  model: str | None = None,
324
525
  allowed_tools: Collection[str] | None = None,
325
526
  allowed_skills: Collection[str] | None = None,
326
- ) -> AsyncStreamEvents:
527
+ stream_output: bool = False,
528
+ ) -> AsyncStreamEvents | ToolAutoResult:
327
529
  prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt)
328
530
  if allowed_tools is not None:
329
531
  allowed_tools = {name.casefold() for name in allowed_tools}
@@ -335,13 +537,26 @@ class Agent:
335
537
  else:
336
538
  tools = list(REGISTRY.values())
337
539
  async with asyncio.timeout(self.settings.model_timeout_seconds):
338
- return await tape.stream_events_async(
339
- prompt=prompt,
340
- system_prompt=self._system_prompt(prompt_text, state=tape.context.state, allowed_skills=allowed_skills),
341
- max_tokens=self.settings.max_tokens,
342
- tools=model_tools(tools),
343
- model=model,
344
- )
540
+ if stream_output:
541
+ return await tape.stream_events_async(
542
+ prompt=prompt,
543
+ system_prompt=self._system_prompt(
544
+ prompt_text, state=tape.context.state, allowed_skills=allowed_skills
545
+ ),
546
+ max_tokens=self.settings.max_tokens,
547
+ tools=model_tools(tools),
548
+ model=model,
549
+ )
550
+ else:
551
+ return await tape.run_tools_async(
552
+ prompt=prompt,
553
+ system_prompt=self._system_prompt(
554
+ prompt_text, state=tape.context.state, allowed_skills=allowed_skills
555
+ ),
556
+ max_tokens=self.settings.max_tokens,
557
+ tools=model_tools(tools),
558
+ model=model,
559
+ )
345
560
 
346
561
  def _system_prompt(self, prompt: str, state: State, allowed_skills: set[str] | None = None) -> str:
347
562
  blocks: list[str] = []
@@ -363,12 +578,24 @@ class _ToolAutoOutcome:
363
578
  error: str = ""
364
579
 
365
580
 
366
- def _resolve_tool_auto_result(final_data: dict[str, Any]) -> _ToolAutoOutcome:
581
+ def _resolve_final_data(final_data: dict[str, Any], error: RepublicError | None) -> _ToolAutoOutcome:
582
+ if final_data.get("tool_calls") or final_data.get("tool_results"):
583
+ return _ToolAutoOutcome(kind="continue")
367
584
  if (text := final_data.get("text")) is not None:
368
585
  return _ToolAutoOutcome(kind="text", text=text)
369
- if final_data.get("tool_calls") or final_data.get("tool_results"):
586
+ error_message = error.message if error else ""
587
+ return _ToolAutoOutcome(kind="error", error=error_message or "unknown error")
588
+
589
+
590
+ def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome:
591
+ if output.kind == "text":
592
+ return _ToolAutoOutcome(kind="text", text=output.text or "")
593
+ if output.kind == "tools" or output.tool_calls or output.tool_results:
370
594
  return _ToolAutoOutcome(kind="continue")
371
- return _ToolAutoOutcome(kind="error", error="unknown error")
595
+ if output.error is None:
596
+ return _ToolAutoOutcome(kind="error", error="tool_auto_error: unknown")
597
+ error_kind = getattr(output.error.kind, "value", str(output.error.kind))
598
+ return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}")
372
599
 
373
600
 
374
601
  def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore, tape_context: TapeContext) -> LLM:
@@ -4,11 +4,15 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import asyncio
7
+ import json
7
8
  import os
8
9
  import subprocess
9
10
  import sys
10
11
  from functools import lru_cache
12
+ from importlib import metadata
11
13
  from pathlib import Path
14
+ from urllib.parse import unquote, urlsplit
15
+ from urllib.request import url2pathname
12
16
 
13
17
  import typer
14
18
 
@@ -79,7 +83,7 @@ def chat(
79
83
 
80
84
  framework = ctx.ensure_object(BubFramework)
81
85
 
82
- manager = ChannelManager(framework, enabled_channels=["cli"])
86
+ manager = ChannelManager(framework, enabled_channels=["cli"], stream_output=True)
83
87
  channel = manager.get_channel("cli")
84
88
  if channel is None:
85
89
  typer.echo("CLI channel not found. Please check your hook implementations.")
@@ -148,15 +152,67 @@ def _build_requirement(spec: str) -> str:
148
152
  return f"git+https://github.com/{repo}.git{ref}"
149
153
  else:
150
154
  # Assume it's a package name in bub-contrib
151
- name, *rest = spec.partition("@")
152
- ref = "".join(rest)
153
- return f"git+{BUB_CONTRIB_REPO}{ref}#subdirectory=packages/{name}"
155
+ name, has_ref, ref = spec.partition("@")
156
+ if has_ref:
157
+ ref = f"@{ref}"
158
+ return f"git+{BUB_CONTRIB_REPO}{ref}#subdirectory=packages/{name}"
159
+ else: # PyPI package name
160
+ return name
161
+
162
+
163
+ def _build_local_requirement_path(url: str, subdirectory: str | None = None) -> str | None:
164
+ parsed = urlsplit(url)
165
+ if parsed.scheme != "file":
166
+ return None
167
+
168
+ path = parsed.path
169
+ if parsed.netloc and parsed.netloc != "localhost":
170
+ path = f"//{parsed.netloc}{path}"
171
+ local_path = Path(url2pathname(unquote(path)))
172
+ if subdirectory:
173
+ local_path /= subdirectory
174
+ return os.fspath(local_path)
175
+
176
+
177
+ def _build_bub_requirement() -> list[str]:
178
+ dist = metadata.distribution("bub")
179
+ dist_name = dist.name
180
+ direct_url_text = dist.read_text("direct_url.json")
181
+ if not direct_url_text:
182
+ return [dist_name]
183
+
184
+ direct_url = json.loads(direct_url_text)
185
+ requirement_url = str(direct_url["url"])
186
+ subdirectory = direct_url.get("subdirectory")
187
+ normalized_subdirectory = subdirectory if isinstance(subdirectory, str) and subdirectory else None
188
+
189
+ local_path = _build_local_requirement_path(requirement_url, normalized_subdirectory)
190
+ if local_path is not None:
191
+ dir_info = direct_url.get("dir_info")
192
+ editable = isinstance(dir_info, dict) and bool(dir_info.get("editable"))
193
+ return ["--editable", local_path] if editable else [local_path]
194
+
195
+ vcs_info = direct_url.get("vcs_info")
196
+ if isinstance(vcs_info, dict):
197
+ vcs = vcs_info.get("vcs")
198
+ requested_revision = vcs_info.get("requested_revision")
199
+ if isinstance(vcs, str) and vcs:
200
+ requirement_url = f"{vcs}+{requirement_url}"
201
+ if isinstance(requested_revision, str) and requested_revision:
202
+ requirement_url = f"{requirement_url}@{requested_revision}"
203
+
204
+ if normalized_subdirectory:
205
+ requirement_url = f"{requirement_url}#subdirectory={normalized_subdirectory}"
206
+
207
+ return [requirement_url]
154
208
 
155
209
 
156
210
  def _ensure_project(project: Path) -> None:
157
211
  if (project / "pyproject.toml").is_file():
158
212
  return
159
213
  _uv("init", "--bare", "--name", "bub-project", "--app", cwd=project)
214
+ bub_requirement = _build_bub_requirement()
215
+ _uv("add", "--active", "--no-sync", *bub_requirement, cwd=project)
160
216
 
161
217
 
162
218
  def install(
@@ -180,8 +236,7 @@ def uninstall(
180
236
  ) -> None:
181
237
  """Uninstall a plugin from Bub's environment."""
182
238
  _ensure_project(project)
183
- _uv("remove", "--active", "--no-sync", *packages, cwd=project)
184
- _uv("sync", "--active", "--frozen", "--inexact", cwd=project)
239
+ _uv("remove", "--active", *packages, cwd=project)
185
240
 
186
241
 
187
242
  def update(
@@ -106,9 +106,13 @@ class BuiltinImpl:
106
106
  return text
107
107
 
108
108
  @hookimpl
109
- async def run_model_stream(self, prompt: str | list[dict], session_id: str, state: State) -> AsyncStreamEvents:
109
+ async def run_model(self, prompt: str | list[dict], session_id: str, state: State) -> str:
110
110
  return await self.agent.run(session_id=session_id, prompt=prompt, state=state)
111
111
 
112
+ @hookimpl
113
+ async def run_model_stream(self, prompt: str | list[dict], session_id: str, state: State) -> AsyncStreamEvents:
114
+ return await self.agent.run_stream(session_id=session_id, prompt=prompt, state=state)
115
+
112
116
  @hookimpl
113
117
  def register_cli_commands(self, app: typer.Typer) -> None:
114
118
  from bub.builtin import cli
@@ -119,8 +123,7 @@ class BuiltinImpl:
119
123
  app.command("hooks", hidden=True)(cli.list_hooks)
120
124
  app.command("gateway")(cli.gateway)
121
125
  app.command("install")(cli.install)
122
- # TODO: uninstall command can't work properly
123
- # app.command("uninstall")(cli.uninstall)
126
+ app.command("uninstall")(cli.uninstall)
124
127
  app.command("update")(cli.update)
125
128
 
126
129
  def _read_agents_file(self, state: State) -> str:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import contextlib
5
5
  import os
6
+ import shutil
6
7
  import uuid
7
8
  from dataclasses import dataclass, field
8
9
 
@@ -30,6 +31,8 @@ class ManagedShell:
30
31
 
31
32
 
32
33
  class ShellManager:
34
+ SHELL = shutil.which("bash") or shutil.which("sh") if os.name != "nt" else None
35
+
33
36
  def __init__(self) -> None:
34
37
  self._shells: dict[str, ManagedShell] = {}
35
38
 
@@ -39,7 +42,7 @@ class ShellManager:
39
42
  cwd=cwd,
40
43
  stdout=asyncio.subprocess.PIPE,
41
44
  stderr=asyncio.subprocess.PIPE,
42
- executable="/bin/bash" if os.name != "nt" else None,
45
+ executable=self.SHELL,
43
46
  )
44
47
  shell = ManagedShell(shell_id=f"bash-{uuid.uuid4().hex[:8]}", cmd=cmd, cwd=cwd, process=process)
45
48
  shell.read_tasks.extend([
@@ -42,9 +42,9 @@ class SearchInput(BaseModel):
42
42
  limit: int = Field(20, description="Maximum number of search results to return.")
43
43
  start: str | None = Field(None, description="Optional start date to filter entries (ISO format).")
44
44
  end: str | None = Field(None, description="Optional end date to filter entries (ISO format).")
45
- kinds: list[EntryKind] = Field(
45
+ kinds: list[str] = Field(
46
46
  default=["message", "tool_result"],
47
- description="Optional list of entry kinds to filter search results.",
47
+ description="Optional list of entry kinds to filter search results. Can include 'event', 'anchor', 'system', 'message', 'tool_call', 'tool_result'.",
48
48
  )
49
49
 
50
50
 
@@ -267,7 +267,7 @@ async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> str:
267
267
  state = {**context.state, "session_id": subagent_session}
268
268
  allowed_tools = resolve_tool_names(param.allowed_tools or None, exclude={"subagent"})
269
269
  output = ""
270
- async for event in await agent.run(
270
+ async for event in await agent.run_stream(
271
271
  session_id=subagent_session,
272
272
  prompt=param.prompt,
273
273
  state=state,