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.
- {bub-0.3.8 → bub-0.4.0}/PKG-INFO +4 -4
- {bub-0.3.8 → bub-0.4.0}/README.md +1 -2
- {bub-0.3.8 → bub-0.4.0}/pyproject.toml +2 -1
- {bub-0.3.8 → bub-0.4.0}/src/bub/__init__.py +11 -3
- {bub-0.3.8 → bub-0.4.0}/src/bub/__main__.py +2 -2
- {bub-0.3.8 → bub-0.4.0}/src/bub/_version.py +2 -2
- bub-0.4.0/src/bub/builtin/agent.py +444 -0
- bub-0.4.0/src/bub/builtin/auth.py +501 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/cli.py +1 -1
- bub-0.4.0/src/bub/builtin/codex_provider.py +491 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/context.py +1 -1
- {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/hook_impl.py +94 -32
- bub-0.4.0/src/bub/builtin/model_runner.py +477 -0
- bub-0.4.0/src/bub/builtin/settings.py +127 -0
- bub-0.4.0/src/bub/builtin/steering.py +37 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/store.py +39 -84
- bub-0.4.0/src/bub/builtin/tape.py +232 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/tools.py +122 -23
- bub-0.4.0/src/bub/channels/__init__.py +17 -0
- bub-0.4.0/src/bub/channels/admission.py +75 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/channels/base.py +20 -2
- bub-0.4.0/src/bub/channels/cli/__init__.py +428 -0
- bub-0.4.0/src/bub/channels/cli/renderer.py +131 -0
- bub-0.4.0/src/bub/channels/contracts.py +28 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/channels/handler.py +1 -1
- bub-0.4.0/src/bub/channels/manager.py +377 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/channels/message.py +3 -1
- {bub-0.3.8 → bub-0.4.0}/src/bub/channels/telegram.py +1 -1
- {bub-0.3.8 → bub-0.4.0}/src/bub/configure.py +25 -1
- {bub-0.3.8 → bub-0.4.0}/src/bub/envelope.py +1 -1
- bub-0.4.0/src/bub/errors.py +40 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/framework.py +140 -46
- bub-0.4.0/src/bub/hooks/__init__.py +5 -0
- bub-0.4.0/src/bub/hooks/interception.py +203 -0
- bub-0.3.8/src/bub/hook_runtime.py → bub-0.4.0/src/bub/hooks/runtime.py +26 -18
- bub-0.4.0/src/bub/hooks/specs.py +199 -0
- bub-0.4.0/src/bub/model_selection.py +24 -0
- bub-0.4.0/src/bub/streaming.py +38 -0
- bub-0.4.0/src/bub/tape.py +366 -0
- bub-0.4.0/src/bub/tools.py +454 -0
- bub-0.4.0/src/bub/turn.py +21 -0
- bub-0.4.0/src/bub/types.py +16 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/utils.py +14 -5
- bub-0.4.0/src/skills/telegram/SKILL.md +184 -0
- bub-0.4.0/src/skills/telegram/scripts/telegram_rich.py +224 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/telegram/scripts/telegram_send.py +30 -3
- bub-0.4.0/tests/test_agent_hooks.py +367 -0
- bub-0.4.0/tests/test_builtin_agent.py +403 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_builtin_cli.py +24 -60
- bub-0.4.0/tests/test_builtin_codex.py +409 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_builtin_hook_impl.py +153 -17
- bub-0.4.0/tests/test_builtin_model_runner.py +95 -0
- bub-0.4.0/tests/test_builtin_tape.py +34 -0
- bub-0.4.0/tests/test_builtin_tools.py +350 -0
- bub-0.4.0/tests/test_channels.py +1270 -0
- bub-0.4.0/tests/test_cli_renderer.py +32 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_file_tape_store_entry_ids.py +7 -6
- {bub-0.3.8 → bub-0.4.0}/tests/test_fork_store_merge_back.py +25 -32
- {bub-0.3.8 → bub-0.4.0}/tests/test_framework.py +70 -4
- {bub-0.3.8 → bub-0.4.0}/tests/test_hook_runtime.py +3 -3
- {bub-0.3.8 → bub-0.4.0}/tests/test_settings.py +2 -4
- {bub-0.3.8 → bub-0.4.0}/tests/test_subagent_tool.py +2 -2
- {bub-0.3.8 → bub-0.4.0}/tests/test_tape_search_output.py +23 -4
- {bub-0.3.8 → bub-0.4.0}/tests/test_tools.py +36 -65
- bub-0.4.0/tests/test_types.py +20 -0
- bub-0.3.8/src/bub/builtin/agent.py +0 -657
- bub-0.3.8/src/bub/builtin/auth.py +0 -59
- bub-0.3.8/src/bub/builtin/settings.py +0 -66
- bub-0.3.8/src/bub/builtin/tape.py +0 -130
- bub-0.3.8/src/bub/channels/__init__.py +0 -5
- bub-0.3.8/src/bub/channels/cli/__init__.py +0 -202
- bub-0.3.8/src/bub/channels/cli/renderer.py +0 -86
- bub-0.3.8/src/bub/channels/manager.py +0 -184
- bub-0.3.8/src/bub/hookspecs.py +0 -109
- bub-0.3.8/src/bub/tools.py +0 -199
- bub-0.3.8/src/bub/types.py +0 -30
- bub-0.3.8/src/skills/telegram/SKILL.md +0 -115
- bub-0.3.8/tests/test_builtin_agent.py +0 -186
- bub-0.3.8/tests/test_builtin_tools.py +0 -191
- bub-0.3.8/tests/test_channels.py +0 -644
- {bub-0.3.8 → bub-0.4.0}/.gitignore +0 -0
- {bub-0.3.8 → bub-0.4.0}/LICENSE +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/__init__.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/builtin/shell_manager.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/inquirer.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/bub/skills.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/README.md +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/gh/SKILL.md +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/tests/conftest.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_cli_help.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_configure.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_envelope.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_image_message.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_skills.py +0 -0
- {bub-0.3.8 → bub-0.4.0}/tests/test_utils.py +0 -0
{bub-0.3.8 → bub-0.4.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.
|
|
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/
|
|
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/
|
|
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
|
-
"
|
|
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.
|
|
14
|
+
from bub.hooks import hookimpl
|
|
15
15
|
from bub.tools import tool
|
|
16
16
|
|
|
17
|
-
__all__ = [
|
|
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
|
|
25
|
-
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
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")
|