bub 0.4.2__tar.gz → 0.4.3__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.4.2 → bub-0.4.3}/PKG-INFO +4 -3
- {bub-0.4.2 → bub-0.4.3}/README.md +1 -1
- {bub-0.4.2 → bub-0.4.3}/pyproject.toml +1 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/_version.py +2 -2
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/agent.py +3 -9
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/hook_impl.py +42 -3
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/model_runner.py +15 -2
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/settings.py +2 -1
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/__init__.py +17 -7
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/renderer.py +19 -19
- bub-0.4.3/src/bub/channels/cli/writers.py +76 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/framework.py +10 -1
- {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/specs.py +12 -2
- bub-0.4.3/src/bub/store.py +537 -0
- {bub-0.4.2/src/bub/builtin → bub-0.4.3/src/bub}/tape.py +163 -10
- {bub-0.4.2 → bub-0.4.3}/src/bub/tools.py +1 -1
- {bub-0.4.2 → bub-0.4.3}/tests/test_agent_hooks.py +2 -2
- {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_agent.py +41 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_hook_impl.py +67 -5
- {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_model_runner.py +19 -4
- {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_tools.py +2 -2
- {bub-0.4.2 → bub-0.4.3}/tests/test_channels.py +98 -14
- bub-0.4.3/tests/test_cli_renderer.py +50 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_file_tape_store_entry_ids.py +2 -2
- {bub-0.4.2 → bub-0.4.3}/tests/test_fork_store_merge_back.py +2 -2
- {bub-0.4.2 → bub-0.4.3}/tests/test_framework.py +30 -0
- bub-0.4.2/tests/test_builtin_tape.py → bub-0.4.3/tests/test_tape.py +21 -3
- bub-0.4.2/src/bub/builtin/store.py +0 -284
- bub-0.4.2/src/bub/channels/cli/writers.py +0 -42
- bub-0.4.2/src/bub/tape.py +0 -366
- bub-0.4.2/tests/test_cli_renderer.py +0 -32
- {bub-0.4.2 → bub-0.4.3}/.gitignore +0 -0
- {bub-0.4.2 → bub-0.4.3}/LICENSE +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/__main__.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/auth.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/cli.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/codex_provider.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/context.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/shell_manager.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/steering.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/builtin/tools.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/admission.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/base.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/ansi_bridge.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/cli/terminal_output.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/contracts.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/handler.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/manager.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/message.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/channels/telegram.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/configure.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/envelope.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/errors.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/__init__.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/interception.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/hooks/runtime.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/inquirer.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/model_selection.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/skills.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/streaming.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/turn.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/types.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/bub/utils.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/README.md +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/gh/SKILL.md +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/SKILL.md +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/license.txt +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/scripts/init_skill.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/skill-creator/scripts/quick_validate.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/SKILL.md +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/scripts/telegram_edit.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/scripts/telegram_rich.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/src/skills/telegram/scripts/telegram_send.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/conftest.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_cli.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_builtin_codex.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_cli_help.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_configure.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_envelope.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_hook_runtime.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_image_message.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_settings.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_skills.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_subagent_tool.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_tape_search_output.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_tools.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_types.py +0 -0
- {bub-0.4.2 → bub-0.4.3}/tests/test_utils.py +0 -0
{bub-0.4.2 → bub-0.4.3}/PKG-INFO
RENAMED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: bub
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
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
|
|
7
7
|
Project-URL: Documentation, https://bub.build
|
|
8
8
|
Author-email: Chojan Shang <psiace@apache.org>, Frost Ming <me@frostming.com>, Yihong <zouzou0208@gmail.com>
|
|
9
|
+
License: Apache-2.0
|
|
9
10
|
License-File: LICENSE
|
|
10
11
|
Classifier: Intended Audience :: Developers
|
|
11
12
|
Classifier: Programming Language :: Python
|
|
@@ -154,7 +155,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
154
155
|
| `BUB_API_BASE` | — | Custom provider endpoint |
|
|
155
156
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
156
157
|
| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
|
|
157
|
-
| `BUB_MAX_STEPS` |
|
|
158
|
+
| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
|
|
158
159
|
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
159
160
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
160
161
|
|
|
@@ -117,7 +117,7 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
|
|
|
117
117
|
| `BUB_API_BASE` | — | Custom provider endpoint |
|
|
118
118
|
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
|
|
119
119
|
| `BUB_COMPLETION_ARGS` | — | JSON object forwarded to each completion call |
|
|
120
|
-
| `BUB_MAX_STEPS` |
|
|
120
|
+
| `BUB_MAX_STEPS` | unlimited | Tool-use loop limit; must be a positive integer |
|
|
121
121
|
| `BUB_MAX_TOKENS` | `16384` | Max tokens per model call |
|
|
122
122
|
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
|
|
123
123
|
|
|
@@ -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.4.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 4,
|
|
21
|
+
__version__ = version = '0.4.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -22,12 +22,12 @@ from bub.builtin.model_runner import (
|
|
|
22
22
|
is_context_length_error,
|
|
23
23
|
)
|
|
24
24
|
from bub.builtin.settings import load_settings
|
|
25
|
-
from bub.builtin.tape import Tape
|
|
26
25
|
from bub.envelope import field_of
|
|
27
26
|
from bub.framework import BubFramework
|
|
28
27
|
from bub.skills import discover_skills, render_skills_prompt
|
|
28
|
+
from bub.store import AsyncTapeStoreAdapter, InMemoryTapeStore, is_async_tape_store
|
|
29
29
|
from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
|
|
30
|
-
from bub.tape import
|
|
30
|
+
from bub.tape import Tape
|
|
31
31
|
from bub.tools import (
|
|
32
32
|
REGISTRY,
|
|
33
33
|
Tool,
|
|
@@ -37,7 +37,6 @@ from bub.tools import (
|
|
|
37
37
|
from bub.turn import TurnState
|
|
38
38
|
from bub.utils import workspace_from_state
|
|
39
39
|
|
|
40
|
-
CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
41
40
|
HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)")
|
|
42
41
|
MAX_AUTO_HANDOFF_RETRIES = 1
|
|
43
42
|
|
|
@@ -291,7 +290,7 @@ class Agent:
|
|
|
291
290
|
)
|
|
292
291
|
return
|
|
293
292
|
|
|
294
|
-
next_prompt = self.
|
|
293
|
+
next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state)
|
|
295
294
|
await tape.append_event(
|
|
296
295
|
"loop.step",
|
|
297
296
|
{
|
|
@@ -398,11 +397,6 @@ class Agent:
|
|
|
398
397
|
blocks.append(skills_prompt)
|
|
399
398
|
return "\n\n".join(blocks)
|
|
400
399
|
|
|
401
|
-
def _continue_prompt(self, tape: Tape) -> str:
|
|
402
|
-
if "context" in tape.context.state:
|
|
403
|
-
return f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
|
|
404
|
-
return CONTINUE_PROMPT
|
|
405
|
-
|
|
406
400
|
def _has_steering_messages(self, state: TurnState) -> bool:
|
|
407
401
|
steering_inbox = self.framework.get_steering_inbox()
|
|
408
402
|
return bool(steering_inbox and steering_inbox.message_count(state) > 0)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
from datetime import datetime
|
|
3
|
+
from difflib import get_close_matches
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import cast
|
|
5
6
|
|
|
@@ -18,9 +19,11 @@ from bub.channels.message import ChannelMessage, MediaItem
|
|
|
18
19
|
from bub.envelope import Envelope, content_of, field_of
|
|
19
20
|
from bub.framework import BubFramework
|
|
20
21
|
from bub.hooks import hookimpl
|
|
22
|
+
from bub.hooks.interception import ToolCall, ToolCallDecision
|
|
21
23
|
from bub.model_selection import ModelChoice, ModelOptions
|
|
22
|
-
from bub.
|
|
23
|
-
from bub.
|
|
24
|
+
from bub.store import TapeStore
|
|
25
|
+
from bub.streaming import AsyncStreamEvents, StreamState
|
|
26
|
+
from bub.tape import Tape, TapeContext
|
|
24
27
|
from bub.turn import TurnState
|
|
25
28
|
|
|
26
29
|
AGENTS_FILE_NAME = "AGENTS.md"
|
|
@@ -57,6 +60,7 @@ When responding to a channel message, you MUST:
|
|
|
57
60
|
Excessively long context may cause model call failures. In this case, you MAY use tape.info to retrieve the token usage and you SHOULD use tape.handoff tool to shorten the retrieved history.
|
|
58
61
|
</context_contract>
|
|
59
62
|
"""
|
|
63
|
+
DEFAULT_CONTINUE_PROMPT = "Continue the task until all targets are completed."
|
|
60
64
|
|
|
61
65
|
|
|
62
66
|
class BuiltinImpl:
|
|
@@ -214,6 +218,13 @@ class BuiltinImpl:
|
|
|
214
218
|
model=state.get("model"),
|
|
215
219
|
)
|
|
216
220
|
|
|
221
|
+
@hookimpl
|
|
222
|
+
def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
|
|
223
|
+
del prompt, state
|
|
224
|
+
if "context" in tape.context.state:
|
|
225
|
+
return f"{DEFAULT_CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
|
|
226
|
+
return DEFAULT_CONTINUE_PROMPT
|
|
227
|
+
|
|
217
228
|
@hookimpl
|
|
218
229
|
def register_cli_commands(self, app: typer.Typer) -> None:
|
|
219
230
|
from bub.builtin import cli
|
|
@@ -355,7 +366,7 @@ class BuiltinImpl:
|
|
|
355
366
|
@hookimpl
|
|
356
367
|
def provide_tape_store(self) -> TapeStore:
|
|
357
368
|
import bub
|
|
358
|
-
from bub.
|
|
369
|
+
from bub.store import FileTapeStore
|
|
359
370
|
|
|
360
371
|
return FileTapeStore(directory=bub.home / "tapes")
|
|
361
372
|
|
|
@@ -378,3 +389,31 @@ class BuiltinImpl:
|
|
|
378
389
|
if channel_router is None:
|
|
379
390
|
return None
|
|
380
391
|
return await channel_router.admit_channel_message(session_id=session_id, message=message, turn=turn)
|
|
392
|
+
|
|
393
|
+
@hookimpl
|
|
394
|
+
async def before_tool_call(
|
|
395
|
+
self,
|
|
396
|
+
call: ToolCall,
|
|
397
|
+
state: TurnState,
|
|
398
|
+
) -> ToolCallDecision | None:
|
|
399
|
+
"""Recover hallucinated/unknown tool names without interrupting the turn.
|
|
400
|
+
|
|
401
|
+
When the model invokes a tool outside the current model-facing tool set,
|
|
402
|
+
replace it with a guidance ``tool_result`` so the model can re-issue a
|
|
403
|
+
valid call on the next step.
|
|
404
|
+
"""
|
|
405
|
+
from bub.tools import REGISTRY, model_tools
|
|
406
|
+
|
|
407
|
+
available_tools = tuple(tool_item.name for tool_item in model_tools(REGISTRY.values()))
|
|
408
|
+
if call.tool in available_tools:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
matches = get_close_matches(call.tool, available_tools, n=3, cutoff=0.6)
|
|
412
|
+
if matches:
|
|
413
|
+
suggestions = "\n".join(f"- {name}" for name in matches)
|
|
414
|
+
guidance = f"Tool `{call.tool}` does not exist. Did you mean one of the following?\n{suggestions}"
|
|
415
|
+
elif "skill" in available_tools:
|
|
416
|
+
guidance = f"Tool `{call.tool}` does not exist. Invoke the `skill` tool to list available skills."
|
|
417
|
+
else:
|
|
418
|
+
guidance = f"Tool `{call.tool}` does not exist. No similar tool is available."
|
|
419
|
+
return ToolCallDecision.replace(guidance)
|
|
@@ -28,7 +28,6 @@ from pydantic import TypeAdapter, ValidationError
|
|
|
28
28
|
|
|
29
29
|
from bub.builtin.codex_provider import OpenaiCodexProvider, should_use_openai_codex_provider
|
|
30
30
|
from bub.builtin.settings import AgentSettings, ModelCandidate
|
|
31
|
-
from bub.builtin.tape import Tape
|
|
32
31
|
from bub.errors import BubError, ErrorKind
|
|
33
32
|
from bub.hooks.interception import (
|
|
34
33
|
AgentHooks,
|
|
@@ -37,6 +36,7 @@ from bub.hooks.interception import (
|
|
|
37
36
|
LlmCallResult,
|
|
38
37
|
)
|
|
39
38
|
from bub.streaming import AsyncStreamEvents, StreamEvent, StreamState
|
|
39
|
+
from bub.tape import Tape
|
|
40
40
|
from bub.tools import Tool, ToolContext, ToolExecutor
|
|
41
41
|
|
|
42
42
|
CONTEXT_LENGTH_PATTERNS = re.compile(
|
|
@@ -461,10 +461,23 @@ def tool_invocation_from_native(
|
|
|
461
461
|
tool_call: ChatCompletionMessageToolCall,
|
|
462
462
|
tool_map: dict[str, Tool],
|
|
463
463
|
) -> tuple[Tool, dict[str, Any]]:
|
|
464
|
+
"""Resolve a model tool call to (runtime tool, arguments).
|
|
465
|
+
|
|
466
|
+
An unknown tool name is not treated as a fatal error: it is surfaced as a
|
|
467
|
+
placeholder ``Tool`` so the invocation flows through ``ToolExecutor`` and
|
|
468
|
+
builtin hooks (e.g. ``before_tool_call``) can recover it into a guidance
|
|
469
|
+
``tool_result`` instead of interrupting the turn. If no hook replaces the
|
|
470
|
+
call, the placeholder raises a clear tool error rather than succeeding with
|
|
471
|
+
an empty result.
|
|
472
|
+
"""
|
|
464
473
|
tool_name, arguments = parse_native_function_call(tool_call)
|
|
465
474
|
tool_obj = tool_map.get(tool_name)
|
|
466
475
|
if tool_obj is None:
|
|
467
|
-
|
|
476
|
+
|
|
477
|
+
def raise_unknown_tool(**_: Any) -> None:
|
|
478
|
+
raise BubError(ErrorKind.TOOL, f"Unknown tool name: {tool_name}.")
|
|
479
|
+
|
|
480
|
+
return Tool(name=tool_name, handler=raise_unknown_tool), arguments
|
|
468
481
|
return tool_obj, arguments
|
|
469
482
|
|
|
470
483
|
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
import pathlib
|
|
5
5
|
import re
|
|
6
|
+
import sys
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
@@ -56,7 +57,7 @@ class AgentSettings(Settings):
|
|
|
56
57
|
fallback_models: list[str] | None = None
|
|
57
58
|
api_key: str | dict[str, str] | None = None
|
|
58
59
|
api_base: str | dict[str, str] | None = None
|
|
59
|
-
max_steps: int =
|
|
60
|
+
max_steps: int = Field(default=sys.maxsize, gt=0)
|
|
60
61
|
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
61
62
|
model_timeout_seconds: int | None = None
|
|
62
63
|
client_args: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -25,7 +25,6 @@ from rich.tree import Tree
|
|
|
25
25
|
|
|
26
26
|
import bub
|
|
27
27
|
from bub.builtin.agent import Agent
|
|
28
|
-
from bub.builtin.tape import TapeInfo
|
|
29
28
|
from bub.channels.admission import AdmitDecision, TurnSnapshot
|
|
30
29
|
from bub.channels.base import Interface
|
|
31
30
|
from bub.channels.cli.ansi_bridge import render_to_ansi
|
|
@@ -39,6 +38,7 @@ from bub.channels.contracts import MessageHandler
|
|
|
39
38
|
from bub.channels.message import ChannelMessage
|
|
40
39
|
from bub.envelope import Envelope, field_of
|
|
41
40
|
from bub.streaming import StreamEvent
|
|
41
|
+
from bub.tape import TapeInfo
|
|
42
42
|
from bub.tools import REGISTRY, tool_call_reporter
|
|
43
43
|
|
|
44
44
|
_GENERATION_SPINNER: str = SPINNERS["dots"]["frames"] # type: ignore[assignment]
|
|
@@ -54,10 +54,12 @@ class _StreamPrinter:
|
|
|
54
54
|
expand_thinking: bool,
|
|
55
55
|
presenter: TerminalPresenter,
|
|
56
56
|
writer: MarkdownWriter | None = None,
|
|
57
|
+
print_end: Callable[[], None] | None = None,
|
|
57
58
|
invalidate: Callable[[], None] | None = None,
|
|
58
59
|
) -> None:
|
|
59
60
|
self._console = console
|
|
60
61
|
self._print_head = print_head
|
|
62
|
+
self._print_end = print_end or (lambda: None)
|
|
61
63
|
self._expand_thinking = expand_thinking
|
|
62
64
|
self._presenter = presenter
|
|
63
65
|
self._reasoning_chars = 0
|
|
@@ -82,13 +84,10 @@ class _StreamPrinter:
|
|
|
82
84
|
|
|
83
85
|
async def _record_reasoning(self, reasoning: str) -> None:
|
|
84
86
|
if not self._expand_thinking:
|
|
85
|
-
if self._reasoning_chars == 0:
|
|
86
|
-
await self._ensure_head()
|
|
87
87
|
self._reasoning_chars += len(reasoning)
|
|
88
88
|
self._invalidate()
|
|
89
89
|
return
|
|
90
90
|
|
|
91
|
-
await self._ensure_head()
|
|
92
91
|
if not self._reasoning_streaming:
|
|
93
92
|
await self._print(Text("[-] Thinking", style="dim"))
|
|
94
93
|
self._reasoning_streaming = True
|
|
@@ -97,19 +96,21 @@ class _StreamPrinter:
|
|
|
97
96
|
async def _print_content(self, content: str) -> bool:
|
|
98
97
|
if not (content.strip() or self.head_printed or self._reasoning_chars or self._reasoning_streaming):
|
|
99
98
|
return False
|
|
100
|
-
await self._ensure_head()
|
|
101
99
|
await self._close_reasoning_stream()
|
|
102
100
|
await self._flush_reasoning()
|
|
101
|
+
if content.strip() or self.head_printed:
|
|
102
|
+
await self._ensure_head()
|
|
103
103
|
await self._write_text(content)
|
|
104
104
|
return True
|
|
105
105
|
|
|
106
106
|
async def finish(self) -> None:
|
|
107
107
|
await self._close_reasoning_stream()
|
|
108
|
-
if self._reasoning_chars:
|
|
109
|
-
await self._ensure_head()
|
|
110
108
|
await self._flush_reasoning()
|
|
111
109
|
if self._writer.has_content():
|
|
110
|
+
await self._ensure_head()
|
|
112
111
|
await self._flush_text()
|
|
112
|
+
else:
|
|
113
|
+
await self._ensure_panel_end()
|
|
113
114
|
|
|
114
115
|
async def _print_stream_boundary(self) -> None:
|
|
115
116
|
await self.finish()
|
|
@@ -122,6 +123,12 @@ class _StreamPrinter:
|
|
|
122
123
|
await self._presenter.write(self._print_head)
|
|
123
124
|
self.head_printed = True
|
|
124
125
|
|
|
126
|
+
async def _ensure_panel_end(self) -> None:
|
|
127
|
+
if not self.head_printed:
|
|
128
|
+
return
|
|
129
|
+
await self._presenter.write(self._print_end)
|
|
130
|
+
self.head_printed = False
|
|
131
|
+
|
|
125
132
|
async def _close_reasoning_stream(self) -> None:
|
|
126
133
|
if not self._reasoning_streaming:
|
|
127
134
|
return
|
|
@@ -163,6 +170,8 @@ class _StreamPrinter:
|
|
|
163
170
|
def commit() -> None:
|
|
164
171
|
self._console.print(finished)
|
|
165
172
|
self._writer.clear()
|
|
173
|
+
self._print_end()
|
|
174
|
+
self.head_printed = False
|
|
166
175
|
|
|
167
176
|
await self._presenter.write(commit)
|
|
168
177
|
self._ansi_cache = None
|
|
@@ -353,6 +362,7 @@ class CliChannel(Interface):
|
|
|
353
362
|
printer = _StreamPrinter(
|
|
354
363
|
console=console,
|
|
355
364
|
print_head=lambda: self._renderer.print_head(message.kind),
|
|
365
|
+
print_end=lambda: self._renderer.print_end(message.kind),
|
|
356
366
|
expand_thinking=self._expand_thinking,
|
|
357
367
|
presenter=self._presenter,
|
|
358
368
|
invalidate=self._invalidate_prompt,
|
|
@@ -10,6 +10,7 @@ from rich.console import Console
|
|
|
10
10
|
from rich.panel import Panel
|
|
11
11
|
from rich.text import Text
|
|
12
12
|
|
|
13
|
+
from bub.channels.cli.writers import PanelEnd, PanelHead
|
|
13
14
|
from bub.channels.message import MessageKind
|
|
14
15
|
|
|
15
16
|
MAX_TOOL_PAYLOAD_CHARS = 4000
|
|
@@ -37,20 +38,10 @@ class CliRenderer:
|
|
|
37
38
|
return
|
|
38
39
|
self.console.print(Text(text, style="bright_black"))
|
|
39
40
|
|
|
40
|
-
def command_output(self, text: str) -> None:
|
|
41
|
-
if not text.strip():
|
|
42
|
-
return
|
|
43
|
-
self.console.print(f"[cyan bold]Command >[/]\n{text}")
|
|
44
|
-
|
|
45
|
-
def assistant_output(self, text: str) -> None:
|
|
46
|
-
if not text.strip():
|
|
47
|
-
return
|
|
48
|
-
self.console.print(f"[blue bold]Assistant >[/]\n{text}")
|
|
49
|
-
|
|
50
41
|
def error(self, text: str) -> None:
|
|
51
42
|
if not text.strip():
|
|
52
43
|
return
|
|
53
|
-
self.console.print(
|
|
44
|
+
self.console.print(Panel(text, title="error", border_style="red"))
|
|
54
45
|
|
|
55
46
|
def input_echo(self, prompt: str, text: str, steering: bool = False) -> None:
|
|
56
47
|
if not text.strip():
|
|
@@ -62,8 +53,7 @@ class CliRenderer:
|
|
|
62
53
|
self.console.print(Text(_format_tool_call(name, args, kwargs), style="magenta"), new_line_start=True)
|
|
63
54
|
|
|
64
55
|
def tool_call_success(self, *, name: str, result: Any, elapsed_ms: float) -> None:
|
|
65
|
-
|
|
66
|
-
self._tool_result(f"completed in {elapsed_ms:.0f} ms", rendered, style="green")
|
|
56
|
+
self._tool_status(f"completed in {elapsed_ms:.0f} ms", style="green")
|
|
67
57
|
|
|
68
58
|
def tool_call_error(self, *, name: str, error: BaseException, elapsed_ms: float) -> None:
|
|
69
59
|
rendered = _format_tool_payload({"type": error.__class__.__name__, "message": str(error)})
|
|
@@ -71,11 +61,19 @@ class CliRenderer:
|
|
|
71
61
|
|
|
72
62
|
def print_head(self, kind: MessageKind) -> None:
|
|
73
63
|
if kind == "command":
|
|
74
|
-
self.console.print("
|
|
64
|
+
self.console.print(PanelHead("Command", border_style="cyan"))
|
|
75
65
|
elif kind == "error":
|
|
76
|
-
self.console.print("
|
|
66
|
+
self.console.print(PanelHead("Error", border_style="red"))
|
|
77
67
|
else:
|
|
78
|
-
self.console.print("
|
|
68
|
+
self.console.print(PanelHead("Assistant", border_style="blue"))
|
|
69
|
+
|
|
70
|
+
def print_end(self, kind: MessageKind) -> None:
|
|
71
|
+
if kind == "command":
|
|
72
|
+
self.console.print(PanelEnd(border_style="cyan"))
|
|
73
|
+
elif kind == "error":
|
|
74
|
+
self.console.print(PanelEnd(border_style="red"))
|
|
75
|
+
else:
|
|
76
|
+
self.console.print(PanelEnd(border_style="blue"))
|
|
79
77
|
|
|
80
78
|
def log(self, message: object) -> None:
|
|
81
79
|
text = str(message).rstrip()
|
|
@@ -83,11 +81,13 @@ class CliRenderer:
|
|
|
83
81
|
self.console.print(text, new_line_start=True)
|
|
84
82
|
|
|
85
83
|
def _tool_result(self, label: str, rendered: str, *, style: str) -> None:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
for line in lines:
|
|
84
|
+
self._tool_status(label, style=style)
|
|
85
|
+
for line in rendered.splitlines() or [""]:
|
|
89
86
|
self.console.print(Text(f" {line}", style="bright_black"), highlight=False)
|
|
90
87
|
|
|
88
|
+
def _tool_status(self, label: str, *, style: str) -> None:
|
|
89
|
+
self.console.print(Text(f" ⎿ {label}", style=style), highlight=False)
|
|
90
|
+
|
|
91
91
|
|
|
92
92
|
def _format_tool_call(name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
93
93
|
params = _format_tool_params(args, kwargs)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Response-scoped Markdown buffering for CLI streaming output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.segment import Segment
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
_MARKDOWN_CODE_THEME = "ansi_dark"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _markdown(content: str) -> Markdown:
|
|
15
|
+
return Markdown(content, code_theme=_MARKDOWN_CODE_THEME)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PanelHead:
|
|
19
|
+
"""The top border of a Panel, printed before its content is streamed."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, title: str, *, border_style: str, width: int | None = None) -> None:
|
|
22
|
+
self._title = title
|
|
23
|
+
self._border_style = border_style
|
|
24
|
+
self._width = width
|
|
25
|
+
|
|
26
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
27
|
+
width = self._width if self._width is not None else options.max_width
|
|
28
|
+
panel = Panel("", title=self._title, border_style=self._border_style, width=width)
|
|
29
|
+
for line in console.render_lines(panel, options.update(width=width), pad=False)[:1]:
|
|
30
|
+
yield from line
|
|
31
|
+
yield Segment.line()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PanelEnd:
|
|
35
|
+
"""The bottom border of a Panel, printed after its content is streamed."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, *, border_style: str, width: int | None = None) -> None:
|
|
38
|
+
self._border_style = border_style
|
|
39
|
+
self._width = width
|
|
40
|
+
|
|
41
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
42
|
+
width = self._width if self._width is not None else options.max_width
|
|
43
|
+
panel = Panel("", border_style=self._border_style, width=width)
|
|
44
|
+
for line in console.render_lines(panel, options.update(width=width), pad=False)[-1:]:
|
|
45
|
+
yield from line
|
|
46
|
+
yield Segment.line()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MarkdownWriter:
|
|
50
|
+
"""Keep one response segment as a single Markdown document.
|
|
51
|
+
|
|
52
|
+
Newlines are Markdown structure, not terminal commit boundaries. The
|
|
53
|
+
buffer is drained only at an explicit model or tool boundary.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._buffer = ""
|
|
58
|
+
|
|
59
|
+
def append(self, text: str) -> None:
|
|
60
|
+
self._buffer += text
|
|
61
|
+
|
|
62
|
+
def render_live(self) -> Markdown | Text:
|
|
63
|
+
if not self._buffer.strip():
|
|
64
|
+
return Text("")
|
|
65
|
+
return _markdown(self._buffer)
|
|
66
|
+
|
|
67
|
+
def render_final(self) -> Markdown | None:
|
|
68
|
+
if not self._buffer.strip():
|
|
69
|
+
return None
|
|
70
|
+
return _markdown(self._buffer.rstrip())
|
|
71
|
+
|
|
72
|
+
def clear(self) -> None:
|
|
73
|
+
self._buffer = ""
|
|
74
|
+
|
|
75
|
+
def has_content(self) -> bool:
|
|
76
|
+
return bool(self._buffer.strip())
|
|
@@ -22,7 +22,9 @@ from bub.hooks.interception import AgentHooks
|
|
|
22
22
|
from bub.hooks.runtime import _SKIP_VALUE, HookRuntime
|
|
23
23
|
from bub.hooks.specs import BUB_HOOK_NAMESPACE, BubHookSpecs
|
|
24
24
|
from bub.model_selection import ModelOptions
|
|
25
|
-
from bub.
|
|
25
|
+
from bub.store import AsyncTapeStore, TapeStore
|
|
26
|
+
from bub.streaming import StreamState
|
|
27
|
+
from bub.tape import Tape, TapeContext
|
|
26
28
|
from bub.turn import TurnResult, TurnState
|
|
27
29
|
from bub.utils import maybe_context_manager
|
|
28
30
|
|
|
@@ -122,6 +124,13 @@ class BubFramework:
|
|
|
122
124
|
prompt = content_of(message)
|
|
123
125
|
return cast("str | list[dict[str, Any]]", prompt)
|
|
124
126
|
|
|
127
|
+
async def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
|
|
128
|
+
"""Build the prompt for the next step of an agent loop."""
|
|
129
|
+
next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=prompt, tape=tape, state=state)
|
|
130
|
+
if isinstance(next_prompt, str):
|
|
131
|
+
return next_prompt
|
|
132
|
+
raise TypeError("hook.continue_prompt must return str")
|
|
133
|
+
|
|
125
134
|
async def build_state(self, message: Envelope, session_id: str) -> TurnState:
|
|
126
135
|
state = {"_runtime_workspace": str(self.workspace), "_runtime_steering_inbox": self.get_steering_inbox()}
|
|
127
136
|
for hook_state in reversed(
|
|
@@ -19,8 +19,9 @@ from bub.hooks.interception import (
|
|
|
19
19
|
ToolCallResult,
|
|
20
20
|
)
|
|
21
21
|
from bub.model_selection import ModelOptions
|
|
22
|
-
from bub.
|
|
23
|
-
from bub.
|
|
22
|
+
from bub.store import AsyncTapeStore, TapeStore
|
|
23
|
+
from bub.streaming import AsyncStreamEvents, StreamState
|
|
24
|
+
from bub.tape import Tape, TapeContext
|
|
24
25
|
from bub.turn import TurnState
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
@@ -64,6 +65,15 @@ class BubHookSpecs:
|
|
|
64
65
|
"""
|
|
65
66
|
raise NotImplementedError
|
|
66
67
|
|
|
68
|
+
@hookspec(firstresult=True)
|
|
69
|
+
def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
|
|
70
|
+
"""Build the prompt used to continue an agent loop.
|
|
71
|
+
|
|
72
|
+
Implementations may be synchronous or asynchronous. The first
|
|
73
|
+
non-``None`` result in hook priority order is used.
|
|
74
|
+
"""
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
67
77
|
@hookspec
|
|
68
78
|
def load_state(self, message: Envelope, session_id: str) -> TurnState:
|
|
69
79
|
"""Load state snapshot for one session."""
|