bub 0.3.8__tar.gz → 0.4.0__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 (100) hide show
  1. {bub-0.3.8 → bub-0.4.0}/PKG-INFO +4 -4
  2. {bub-0.3.8 → bub-0.4.0}/README.md +1 -2
  3. {bub-0.3.8 → bub-0.4.0}/pyproject.toml +2 -1
  4. {bub-0.3.8 → bub-0.4.0}/src/bub/__init__.py +11 -3
  5. {bub-0.3.8 → bub-0.4.0}/src/bub/__main__.py +2 -2
  6. {bub-0.3.8 → bub-0.4.0}/src/bub/_version.py +2 -2
  7. bub-0.4.0/src/bub/builtin/agent.py +444 -0
  8. bub-0.4.0/src/bub/builtin/auth.py +501 -0
  9. {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/cli.py +1 -1
  10. bub-0.4.0/src/bub/builtin/codex_provider.py +491 -0
  11. {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/context.py +1 -1
  12. {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/hook_impl.py +94 -32
  13. bub-0.4.0/src/bub/builtin/model_runner.py +477 -0
  14. bub-0.4.0/src/bub/builtin/settings.py +127 -0
  15. bub-0.4.0/src/bub/builtin/steering.py +37 -0
  16. {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/store.py +39 -84
  17. bub-0.4.0/src/bub/builtin/tape.py +232 -0
  18. {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/tools.py +122 -23
  19. bub-0.4.0/src/bub/channels/__init__.py +17 -0
  20. bub-0.4.0/src/bub/channels/admission.py +75 -0
  21. {bub-0.3.8 → bub-0.4.0}/src/bub/channels/base.py +20 -2
  22. bub-0.4.0/src/bub/channels/cli/__init__.py +428 -0
  23. bub-0.4.0/src/bub/channels/cli/renderer.py +131 -0
  24. bub-0.4.0/src/bub/channels/contracts.py +28 -0
  25. {bub-0.3.8 → bub-0.4.0}/src/bub/channels/handler.py +1 -1
  26. bub-0.4.0/src/bub/channels/manager.py +377 -0
  27. {bub-0.3.8 → bub-0.4.0}/src/bub/channels/message.py +3 -1
  28. {bub-0.3.8 → bub-0.4.0}/src/bub/channels/telegram.py +1 -1
  29. {bub-0.3.8 → bub-0.4.0}/src/bub/configure.py +25 -1
  30. {bub-0.3.8 → bub-0.4.0}/src/bub/envelope.py +1 -1
  31. bub-0.4.0/src/bub/errors.py +40 -0
  32. {bub-0.3.8 → bub-0.4.0}/src/bub/framework.py +140 -46
  33. bub-0.4.0/src/bub/hooks/__init__.py +5 -0
  34. bub-0.4.0/src/bub/hooks/interception.py +203 -0
  35. bub-0.3.8/src/bub/hook_runtime.py → bub-0.4.0/src/bub/hooks/runtime.py +26 -18
  36. bub-0.4.0/src/bub/hooks/specs.py +199 -0
  37. bub-0.4.0/src/bub/model_selection.py +24 -0
  38. bub-0.4.0/src/bub/streaming.py +38 -0
  39. bub-0.4.0/src/bub/tape.py +366 -0
  40. bub-0.4.0/src/bub/tools.py +454 -0
  41. bub-0.4.0/src/bub/turn.py +21 -0
  42. bub-0.4.0/src/bub/types.py +16 -0
  43. {bub-0.3.8 → bub-0.4.0}/src/bub/utils.py +14 -5
  44. bub-0.4.0/src/skills/telegram/SKILL.md +184 -0
  45. bub-0.4.0/src/skills/telegram/scripts/telegram_rich.py +224 -0
  46. {bub-0.3.8 → bub-0.4.0}/src/skills/telegram/scripts/telegram_send.py +30 -3
  47. bub-0.4.0/tests/test_agent_hooks.py +367 -0
  48. bub-0.4.0/tests/test_builtin_agent.py +403 -0
  49. {bub-0.3.8 → bub-0.4.0}/tests/test_builtin_cli.py +24 -60
  50. bub-0.4.0/tests/test_builtin_codex.py +409 -0
  51. {bub-0.3.8 → bub-0.4.0}/tests/test_builtin_hook_impl.py +153 -17
  52. bub-0.4.0/tests/test_builtin_model_runner.py +95 -0
  53. bub-0.4.0/tests/test_builtin_tape.py +34 -0
  54. bub-0.4.0/tests/test_builtin_tools.py +350 -0
  55. bub-0.4.0/tests/test_channels.py +1270 -0
  56. bub-0.4.0/tests/test_cli_renderer.py +32 -0
  57. {bub-0.3.8 → bub-0.4.0}/tests/test_file_tape_store_entry_ids.py +7 -6
  58. {bub-0.3.8 → bub-0.4.0}/tests/test_fork_store_merge_back.py +25 -32
  59. {bub-0.3.8 → bub-0.4.0}/tests/test_framework.py +70 -4
  60. {bub-0.3.8 → bub-0.4.0}/tests/test_hook_runtime.py +3 -3
  61. {bub-0.3.8 → bub-0.4.0}/tests/test_settings.py +2 -4
  62. {bub-0.3.8 → bub-0.4.0}/tests/test_subagent_tool.py +2 -2
  63. {bub-0.3.8 → bub-0.4.0}/tests/test_tape_search_output.py +23 -4
  64. {bub-0.3.8 → bub-0.4.0}/tests/test_tools.py +36 -65
  65. bub-0.4.0/tests/test_types.py +20 -0
  66. bub-0.3.8/src/bub/builtin/agent.py +0 -657
  67. bub-0.3.8/src/bub/builtin/auth.py +0 -59
  68. bub-0.3.8/src/bub/builtin/settings.py +0 -66
  69. bub-0.3.8/src/bub/builtin/tape.py +0 -130
  70. bub-0.3.8/src/bub/channels/__init__.py +0 -5
  71. bub-0.3.8/src/bub/channels/cli/__init__.py +0 -202
  72. bub-0.3.8/src/bub/channels/cli/renderer.py +0 -86
  73. bub-0.3.8/src/bub/channels/manager.py +0 -184
  74. bub-0.3.8/src/bub/hookspecs.py +0 -109
  75. bub-0.3.8/src/bub/tools.py +0 -199
  76. bub-0.3.8/src/bub/types.py +0 -30
  77. bub-0.3.8/src/skills/telegram/SKILL.md +0 -115
  78. bub-0.3.8/tests/test_builtin_agent.py +0 -186
  79. bub-0.3.8/tests/test_builtin_tools.py +0 -191
  80. bub-0.3.8/tests/test_channels.py +0 -644
  81. {bub-0.3.8 → bub-0.4.0}/.gitignore +0 -0
  82. {bub-0.3.8 → bub-0.4.0}/LICENSE +0 -0
  83. {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/__init__.py +0 -0
  84. {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/shell_manager.py +0 -0
  85. {bub-0.3.8 → bub-0.4.0}/src/bub/inquirer.py +0 -0
  86. {bub-0.3.8 → bub-0.4.0}/src/bub/skills.py +0 -0
  87. {bub-0.3.8 → bub-0.4.0}/src/skills/README.md +0 -0
  88. {bub-0.3.8 → bub-0.4.0}/src/skills/gh/SKILL.md +0 -0
  89. {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/SKILL.md +0 -0
  90. {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/license.txt +0 -0
  91. {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/scripts/init_skill.py +0 -0
  92. {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
  93. {bub-0.3.8 → bub-0.4.0}/src/skills/telegram/scripts/telegram_edit.py +0 -0
  94. {bub-0.3.8 → bub-0.4.0}/tests/conftest.py +0 -0
  95. {bub-0.3.8 → bub-0.4.0}/tests/test_cli_help.py +0 -0
  96. {bub-0.3.8 → bub-0.4.0}/tests/test_configure.py +0 -0
  97. {bub-0.3.8 → bub-0.4.0}/tests/test_envelope.py +0 -0
  98. {bub-0.3.8 → bub-0.4.0}/tests/test_image_message.py +0 -0
  99. {bub-0.3.8 → bub-0.4.0}/tests/test_skills.py +0 -0
  100. {bub-0.3.8 → bub-0.4.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bub
3
- Version: 0.3.8
3
+ Version: 0.4.0
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
@@ -17,6 +17,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
17
  Requires-Python: <4.0,>=3.12
18
18
  Requires-Dist: aiohttp>=3.13.3
19
19
  Requires-Dist: any-llm-sdk[anthropic]
20
+ Requires-Dist: authlib>=1.7.2
20
21
  Requires-Dist: httpx[socks]>=0.28.1
21
22
  Requires-Dist: inquirer-textual>=0.5.1
22
23
  Requires-Dist: loguru>=0.7.2
@@ -27,9 +28,9 @@ Requires-Dist: pydantic>=2.0.0
27
28
  Requires-Dist: python-telegram-bot>=21.0
28
29
  Requires-Dist: pyyaml>=6.0.0
29
30
  Requires-Dist: rapidfuzz>=3.14.3
30
- Requires-Dist: republic>=0.5.4
31
31
  Requires-Dist: rich>=13.0.0
32
32
  Requires-Dist: typer>=0.9.0
33
+ Requires-Dist: typing-extensions>=4.13.0
33
34
  Provides-Extra: logfire
34
35
  Requires-Dist: logfire>=4.31.0; extra == 'logfire'
35
36
  Description-Content-Type: text/markdown
@@ -98,7 +99,7 @@ Builtins are registered first. External plugins load after them. At runtime, lat
98
99
  Key source files:
99
100
 
100
101
  - Turn orchestrator: [`src/bub/framework.py`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py)
101
- - Hook contract: [`src/bub/hookspecs.py`](https://github.com/bubbuild/bub/blob/main/src/bub/hookspecs.py)
102
+ - Hook contract: [`src/bub/hooks/specs.py`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/specs.py)
102
103
  - Builtin hooks: [`src/bub/builtin/hook_impl.py`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/hook_impl.py)
103
104
  - Skill discovery: [`src/bub/skills.py`](https://github.com/bubbuild/bub/blob/main/src/bub/skills.py)
104
105
 
@@ -151,7 +152,6 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
151
152
  | `BUB_MODEL` | `openrouter:openrouter/free` | Model identifier |
152
153
  | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
153
154
  | `BUB_API_BASE` | — | Custom provider endpoint |
154
- | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
155
155
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
156
156
  | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
157
157
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
@@ -62,7 +62,7 @@ Builtins are registered first. External plugins load after them. At runtime, lat
62
62
  Key source files:
63
63
 
64
64
  - Turn orchestrator: [`src/bub/framework.py`](https://github.com/bubbuild/bub/blob/main/src/bub/framework.py)
65
- - Hook contract: [`src/bub/hookspecs.py`](https://github.com/bubbuild/bub/blob/main/src/bub/hookspecs.py)
65
+ - Hook contract: [`src/bub/hooks/specs.py`](https://github.com/bubbuild/bub/blob/main/src/bub/hooks/specs.py)
66
66
  - Builtin hooks: [`src/bub/builtin/hook_impl.py`](https://github.com/bubbuild/bub/blob/main/src/bub/builtin/hook_impl.py)
67
67
  - Skill discovery: [`src/bub/skills.py`](https://github.com/bubbuild/bub/blob/main/src/bub/skills.py)
68
68
 
@@ -115,7 +115,6 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
115
115
  | `BUB_MODEL` | `openrouter:openrouter/free` | Model identifier |
116
116
  | `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
117
117
  | `BUB_API_BASE` | — | Custom provider endpoint |
118
- | `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
119
118
  | `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
120
119
  | `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
121
120
  | `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
@@ -26,7 +26,7 @@ dependencies = [
26
26
  "pluggy>=1.6.0",
27
27
  "inquirer-textual>=0.5.1",
28
28
  "typer>=0.9.0",
29
- "republic>=0.5.4",
29
+ "authlib>=1.7.2",
30
30
  "any-llm-sdk[anthropic]",
31
31
  "rich>=13.0.0",
32
32
  "prompt-toolkit>=3.0.0",
@@ -35,6 +35,7 @@ dependencies = [
35
35
  "rapidfuzz>=3.14.3",
36
36
  "aiohttp>=3.13.3",
37
37
  "httpx[socks]>=0.28.1",
38
+ "typing-extensions>=4.13.0",
38
39
  ]
39
40
 
40
41
  [project.urls]
@@ -11,10 +11,18 @@ from typing import TYPE_CHECKING
11
11
 
12
12
  from bub.configure import Settings, config, ensure_config
13
13
  from bub.framework import DEFAULT_HOME, BubFramework
14
- from bub.hookspecs import hookimpl
14
+ from bub.hooks import hookimpl
15
15
  from bub.tools import tool
16
16
 
17
- __all__ = ["BubFramework", "Settings", "config", "ensure_config", "home", "hookimpl", "tool"]
17
+ __all__ = [
18
+ "BubFramework",
19
+ "Settings",
20
+ "config",
21
+ "ensure_config",
22
+ "home",
23
+ "hookimpl",
24
+ "tool",
25
+ ]
18
26
 
19
27
  try:
20
28
  __version__ = import_module("bub._version").version
@@ -32,6 +40,6 @@ if TYPE_CHECKING:
32
40
  def __getattr__(name: str):
33
41
  if name == "home":
34
42
  if "BUB_HOME" in os.environ:
35
- return Path(os.environ["BUB_HOME"])
43
+ return Path(os.path.expanduser(os.environ["BUB_HOME"]))
36
44
  return DEFAULT_HOME
37
45
  raise AttributeError(f"module {__name__} has no attribute {name}")
@@ -21,8 +21,8 @@ def _instrument_bub() -> None:
21
21
 
22
22
  logfire.configure()
23
23
  logger.add(LogfireHandler(), format="{message}")
24
- except ImportError:
25
- pass
24
+ except Exception as exc:
25
+ logger.debug("logfire instrumentation disabled: {}", exc)
26
26
 
27
27
 
28
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.8'
22
- __version_tuple__ = version_tuple = (0, 3, 8)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,444 @@
1
+ """Runtime engine to process prompts with any-llm-sdk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import re
8
+ import shlex
9
+ import time
10
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Collection, Coroutine, Iterable
11
+ from contextlib import AsyncExitStack
12
+ from dataclasses import dataclass, replace
13
+ from datetime import UTC, datetime
14
+ from functools import cached_property
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from loguru import logger
19
+
20
+ from bub.builtin.model_runner import (
21
+ ModelRunner,
22
+ is_context_length_error,
23
+ )
24
+ from bub.builtin.settings import load_settings
25
+ from bub.builtin.tape import Tape
26
+ from bub.envelope import field_of
27
+ from bub.framework import BubFramework
28
+ from bub.skills import discover_skills, render_skills_prompt
29
+ from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
30
+ from bub.tape import AsyncTapeStoreAdapter, InMemoryTapeStore, is_async_tape_store
31
+ from bub.tools import (
32
+ REGISTRY,
33
+ Tool,
34
+ ToolContext,
35
+ )
36
+ from bub.turn import TurnState
37
+ from bub.utils import workspace_from_state
38
+
39
+ CONTINUE_PROMPT = "Continue the task until all targets are completed."
40
+ HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
41
+ MAX_AUTO_HANDOFF_RETRIES = 1
42
+
43
+
44
+ class Agent:
45
+ """Agent that processes prompts using hooks, tools, tape, and any-llm-sdk."""
46
+
47
+ def __init__(self, framework: BubFramework) -> None:
48
+ self.settings = load_settings()
49
+ self.framework = framework
50
+ self.model_runner = ModelRunner(self.settings, hooks=framework.get_agent_hooks())
51
+
52
+ @cached_property
53
+ def tape(self) -> Tape:
54
+ import bub
55
+
56
+ tape_store = self.framework.get_tape_store()
57
+ if tape_store is None:
58
+ tape_store = InMemoryTapeStore()
59
+ if not is_async_tape_store(tape_store):
60
+ tape_store = AsyncTapeStoreAdapter(tape_store)
61
+ return Tape(bub.home / "tapes", tape_store, self.framework.build_tape_context())
62
+
63
+ @staticmethod
64
+ def _events_from_iterable(iterable: Iterable) -> AsyncStreamEvents:
65
+ async def generator() -> AsyncIterator:
66
+ for item in iterable:
67
+ yield item
68
+
69
+ return AsyncStreamEvents(generator())
70
+
71
+ @staticmethod
72
+ def _events_with_callback(
73
+ events: AsyncStreamEvents, callback: Callable[[], Coroutine[Any, Any, Any]]
74
+ ) -> AsyncStreamEvents:
75
+ async def generator() -> AsyncIterator[StreamEvent]:
76
+ try:
77
+ async for event in events:
78
+ yield event
79
+ finally:
80
+ await callback()
81
+
82
+ return AsyncStreamEvents(generator(), state=events._state)
83
+
84
+ async def run_stream(
85
+ self,
86
+ *,
87
+ session_id: str,
88
+ prompt: str | list[dict],
89
+ state: TurnState,
90
+ model: str | None = None,
91
+ allowed_skills: Collection[str] | None = None,
92
+ allowed_tools: Collection[str] | None = None,
93
+ ) -> AsyncStreamEvents:
94
+ if not prompt:
95
+ return self._events_from_iterable([
96
+ StreamEvent("text", {"delta": "error: empty prompt"}),
97
+ StreamEvent("final", {"text": "error: empty prompt", "ok": False}),
98
+ ])
99
+
100
+ state.setdefault("session_id", session_id)
101
+ tape = self.tape.session_tape(
102
+ session_id, workspace_from_state(state), context=replace(self.tape.context, state=state)
103
+ )
104
+ merge_back = not session_id.startswith("temp/")
105
+ stack = AsyncExitStack()
106
+ # The fork_tape context manager must not be exited until the last chunk of the stream is consumed.
107
+ tape = await stack.enter_async_context(tape.fork_tape(merge_back=merge_back))
108
+ await tape.ensure_bootstrap_anchor()
109
+ if isinstance(prompt, str) and prompt.strip().startswith(","):
110
+ result = await self._run_command(tape=tape, line=prompt.strip())
111
+ events = self._events_from_iterable([
112
+ StreamEvent("text", {"delta": result}),
113
+ StreamEvent("final", {"text": result, "ok": True}),
114
+ ])
115
+ else:
116
+ events = await self._agent_loop(
117
+ tape=tape,
118
+ prompt=prompt,
119
+ model=model,
120
+ allowed_skills=allowed_skills,
121
+ allowed_tools=allowed_tools,
122
+ )
123
+
124
+ return self._events_with_callback(events, callback=stack.aclose)
125
+
126
+ async def _run_command(self, tape: Tape, *, line: str) -> str:
127
+ line = line[1:].strip()
128
+ if not line:
129
+ raise ValueError("empty command")
130
+
131
+ name, arg_tokens = _parse_internal_command(line)
132
+ start = time.monotonic()
133
+ context = ToolContext(tape=tape, run_id="run_command", state=tape.context.state)
134
+ output = ""
135
+ status = "ok"
136
+ try:
137
+ if name not in REGISTRY:
138
+ output = await REGISTRY["bash"].run(context=context, cmd=line)
139
+ else:
140
+ args = _parse_args(arg_tokens)
141
+ if REGISTRY[name].context:
142
+ args.kwargs["context"] = context
143
+ output = REGISTRY[name].run(*args.positional, **args.kwargs)
144
+ if inspect.isawaitable(output):
145
+ output = await output
146
+ except Exception as exc:
147
+ status = "error"
148
+ output = f"{exc!s}"
149
+ raise
150
+ else:
151
+ return output if isinstance(output, str) else str(output)
152
+ finally:
153
+ elapsed_ms = int((time.monotonic() - start) * 1000)
154
+ output_text = output if isinstance(output, str) else str(output)
155
+
156
+ event_payload = {
157
+ "raw": line,
158
+ "name": name,
159
+ "status": status,
160
+ "elapsed_ms": elapsed_ms,
161
+ "output": output_text,
162
+ "date": datetime.now(UTC).isoformat(),
163
+ }
164
+ await tape.append_event("command", event_payload)
165
+
166
+ async def _agent_loop(
167
+ self,
168
+ *,
169
+ tape: Tape,
170
+ prompt: str | list[dict],
171
+ model: str | None = None,
172
+ allowed_skills: Collection[str] | None = None,
173
+ allowed_tools: Collection[str] | None = None,
174
+ ) -> AsyncStreamEvents:
175
+ next_prompt: str | list[dict] = prompt
176
+ display_model = model or self.settings.model
177
+ await tape.append_event(
178
+ "loop.start",
179
+ {
180
+ "model": display_model,
181
+ "prompt": prompt,
182
+ "allowed_skills": list(allowed_skills) if allowed_skills else None,
183
+ "allowed_tools": list(allowed_tools) if allowed_tools else None,
184
+ },
185
+ )
186
+ state = StreamState()
187
+ iterator = self._stream_events_with_auto_handoff(
188
+ tape=tape,
189
+ prompt=next_prompt,
190
+ state=state,
191
+ model=model,
192
+ allowed_skills=allowed_skills,
193
+ allowed_tools=allowed_tools,
194
+ )
195
+ return AsyncStreamEvents(iterator, state=state)
196
+
197
+ async def _stream_events_with_auto_handoff(
198
+ self,
199
+ tape: Tape,
200
+ prompt: str | list[dict],
201
+ state: StreamState,
202
+ model: str | None = None,
203
+ allowed_skills: Collection[str] | None = None,
204
+ allowed_tools: Collection[str] | None = None,
205
+ ) -> AsyncGenerator[StreamEvent, None]:
206
+ auto_handoff_remaining = MAX_AUTO_HANDOFF_RETRIES
207
+ display_model = model or self.settings.model
208
+ next_prompt = prompt
209
+ for step in range(1, self.settings.max_steps + 1):
210
+ start = time.monotonic()
211
+ should_continue = False
212
+ logger.info("loop.step step={} tape={} model={}", step, tape.name, display_model)
213
+ await tape.append_event("loop.step.start", {"step": step, "prompt": next_prompt})
214
+ try:
215
+ output = await self._run_once(
216
+ tape=tape,
217
+ prompt=next_prompt,
218
+ model=model,
219
+ allowed_skills=allowed_skills,
220
+ allowed_tools=allowed_tools,
221
+ )
222
+ async for event in output:
223
+ yield event
224
+ if event.kind == "error":
225
+ elapsed_ms = int((time.monotonic() - start) * 1000)
226
+ await tape.append_event(
227
+ "loop.step",
228
+ {
229
+ "step": step,
230
+ "elapsed_ms": elapsed_ms,
231
+ "status": "error",
232
+ "error": event.data.get("message", ""),
233
+ "date": datetime.now(UTC).isoformat(),
234
+ },
235
+ )
236
+ elif event.kind == "final":
237
+ should_continue = bool(event.data.get("tool_calls") or event.data.get("tool_results"))
238
+ except Exception as exc:
239
+ error_message = f"{exc!s}"
240
+ elapsed_ms = int((time.monotonic() - start) * 1000)
241
+ if auto_handoff_remaining > 0 and is_context_length_error(error_message):
242
+ auto_handoff_remaining -= 1
243
+ logger.warning(
244
+ "auto_handoff: context length exceeded, performing automatic handoff. tape={} step={}",
245
+ tape.name,
246
+ step,
247
+ )
248
+ await tape.handoff(
249
+ name="auto_handoff/context_overflow",
250
+ state={"reason": "context_length_exceeded", "error": error_message},
251
+ )
252
+ await tape.append_event(
253
+ "loop.step",
254
+ {
255
+ "step": step,
256
+ "elapsed_ms": elapsed_ms,
257
+ "status": "auto_handoff",
258
+ "error": error_message,
259
+ "date": datetime.now(UTC).isoformat(),
260
+ },
261
+ )
262
+ next_prompt = prompt
263
+ continue
264
+
265
+ await tape.append_event(
266
+ "loop.step",
267
+ {
268
+ "step": step,
269
+ "elapsed_ms": elapsed_ms,
270
+ "status": "error",
271
+ "error": error_message,
272
+ "date": datetime.now(UTC).isoformat(),
273
+ },
274
+ )
275
+ raise
276
+
277
+ state.error = output.error
278
+ state.usage = output.usage
279
+ elapsed_ms = int((time.monotonic() - start) * 1000)
280
+ should_continue = should_continue or self._has_steering_messages(tape.context.state)
281
+ if not should_continue:
282
+ await tape.append_event(
283
+ "loop.step",
284
+ {
285
+ "step": step,
286
+ "elapsed_ms": elapsed_ms,
287
+ "status": "ok",
288
+ "date": datetime.now(UTC).isoformat(),
289
+ },
290
+ )
291
+ return
292
+
293
+ next_prompt = self._continue_prompt(tape)
294
+ await tape.append_event(
295
+ "loop.step",
296
+ {
297
+ "step": step,
298
+ "elapsed_ms": elapsed_ms,
299
+ "status": "continue",
300
+ "date": datetime.now(UTC).isoformat(),
301
+ },
302
+ )
303
+
304
+ raise RuntimeError(f"max_steps_reached={self.settings.max_steps}")
305
+
306
+ def _load_skills_prompt(self, prompt: str, workspace: Path, allowed_skills: set[str] | None = None) -> str:
307
+ skill_index = {
308
+ skill.name.casefold(): skill
309
+ for skill in discover_skills(workspace)
310
+ if allowed_skills is None or skill.name.casefold() in allowed_skills
311
+ }
312
+ expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys())
313
+ return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills)
314
+
315
+ async def _run_once(
316
+ self,
317
+ *,
318
+ tape: Tape,
319
+ prompt: str | list[dict],
320
+ model: str | None = None,
321
+ allowed_tools: Collection[str] | None = None,
322
+ allowed_skills: Collection[str] | None = None,
323
+ ) -> AsyncStreamEvents:
324
+ prompt_text = prompt if isinstance(prompt, str) else _extract_text_from_parts(prompt)
325
+ if allowed_tools is not None:
326
+ from bub.builtin.tools import resolve_tool_names
327
+
328
+ allowed_tools = resolve_tool_names(allowed_tools)
329
+ if allowed_skills is not None:
330
+ allowed_skills = {name.casefold() for name in allowed_skills}
331
+ tape.context.state["allowed_skills"] = list(allowed_skills)
332
+ if allowed_tools is not None:
333
+ tools = [tool for tool in REGISTRY.values() if tool.name in allowed_tools]
334
+ else:
335
+ tools = list(REGISTRY.values())
336
+ return await self._run_once_stream(
337
+ tape=tape,
338
+ prompt=prompt,
339
+ prompt_text=prompt_text,
340
+ model=model,
341
+ allowed_skills=allowed_skills,
342
+ tools=tools,
343
+ )
344
+
345
+ async def _run_once_stream(
346
+ self,
347
+ *,
348
+ tape: Tape,
349
+ prompt: str | list[dict],
350
+ prompt_text: str,
351
+ model: str | None,
352
+ allowed_skills: set[str] | None,
353
+ tools: list[Tool],
354
+ ) -> AsyncStreamEvents:
355
+ from bub.builtin.tools import model_tools
356
+
357
+ system_prompt = self._system_prompt(
358
+ prompt_text, state=tape.context.state, allowed_skills=allowed_skills, tools=tools
359
+ )
360
+ resolved_model = model or self.settings.model
361
+
362
+ model_tools_for_call = model_tools(tools)
363
+ steering_inbox = self.framework.get_steering_inbox()
364
+ steering_envelopes = await steering_inbox.drain_messages(tape.context.state) if steering_inbox else []
365
+ steering_messages = list(
366
+ await asyncio.gather(*[
367
+ self.framework.build_prompt(
368
+ message, session_id=field_of(message, "session_id"), state=tape.context.state
369
+ )
370
+ for message in steering_envelopes
371
+ ])
372
+ )
373
+ return self.model_runner.run(
374
+ tape=tape,
375
+ model=resolved_model,
376
+ tools=model_tools_for_call,
377
+ system_prompt=system_prompt,
378
+ prompt=prompt,
379
+ steering_messages=steering_messages,
380
+ )
381
+
382
+ def _system_prompt(
383
+ self,
384
+ prompt: str,
385
+ state: TurnState,
386
+ allowed_skills: set[str] | None = None,
387
+ tools: Iterable[Tool] | None = None,
388
+ ) -> str:
389
+ from bub.builtin.tools import render_tools_prompt
390
+
391
+ blocks: list[str] = []
392
+ if result := self.framework.get_system_prompt(prompt=prompt, state=state):
393
+ blocks.append(result)
394
+ tools_prompt = render_tools_prompt(tools if tools is not None else REGISTRY.values())
395
+ if tools_prompt:
396
+ blocks.append(tools_prompt)
397
+ workspace = workspace_from_state(state)
398
+ if skills_prompt := self._load_skills_prompt(prompt, workspace, allowed_skills):
399
+ blocks.append(skills_prompt)
400
+ return "\n\n".join(blocks)
401
+
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
+ def _has_steering_messages(self, state: TurnState) -> bool:
408
+ steering_inbox = self.framework.get_steering_inbox()
409
+ return bool(steering_inbox and steering_inbox.message_count(state) > 0)
410
+
411
+
412
+ @dataclass(frozen=True)
413
+ class Args:
414
+ positional: list[str]
415
+ kwargs: dict[str, Any]
416
+
417
+
418
+ def _parse_internal_command(line: str) -> tuple[str, list[str]]:
419
+ body = line.strip()
420
+ words = shlex.split(body)
421
+ if not words:
422
+ return "", []
423
+ return words[0], words[1:]
424
+
425
+
426
+ def _parse_args(args_tokens: list[str]) -> Args:
427
+ positional: list[str] = []
428
+ kwargs: dict[str, str] = {}
429
+ first_kwarg = False
430
+ for token in args_tokens:
431
+ if "=" in token:
432
+ key, value = token.split("=", 1)
433
+ kwargs[key] = value
434
+ first_kwarg = True
435
+ elif first_kwarg:
436
+ raise ValueError(f"positional argument '{token}' cannot appear after keyword arguments")
437
+ else:
438
+ positional.append(token)
439
+ return Args(positional=positional, kwargs=kwargs)
440
+
441
+
442
+ def _extract_text_from_parts(parts: list[dict]) -> str:
443
+ """Extract text content from multimodal content parts."""
444
+ return "\n".join(p.get("text", "") for p in parts if p.get("type") == "text")