Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/bub/builtin/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ async def _stream_events_with_auto_handoff(
)
return

next_prompt = await self.framework.continue_prompt(tape=tape, state=state)
next_prompt = await self.framework.continue_prompt(prompt=next_prompt, tape=tape, state=state)
await tape.append_event(
"loop.step",
{
Expand Down
4 changes: 2 additions & 2 deletions src/bub/builtin/hook_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ async def run_model_stream(self, prompt: str | list[dict], session_id: str, stat
)

@hookimpl
def continue_prompt(self, tape: Tape, state: StreamState) -> str:
del state
def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
del prompt, state
if "context" in tape.context.state:
return f"{DEFAULT_CONTINUE_PROMPT} [context: {tape.context.state['context']}]"
return DEFAULT_CONTINUE_PROMPT
Expand Down
8 changes: 4 additions & 4 deletions src/bub/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,11 @@ async def build_prompt(
prompt = content_of(message)
return cast("str | list[dict[str, Any]]", prompt)

async def continue_prompt(self, tape: Tape, state: StreamState) -> str:
async def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
"""Build the prompt for the next step of an agent loop."""
prompt = await self._hook_runtime.call_first("continue_prompt", tape=tape, state=state)
if isinstance(prompt, str):
return prompt
next_prompt = await self._hook_runtime.call_first("continue_prompt", prompt=prompt, tape=tape, state=state)
if isinstance(next_prompt, str):
return next_prompt
raise TypeError("hook.continue_prompt must return str")

async def build_state(self, message: Envelope, session_id: str) -> TurnState:
Expand Down
2 changes: 1 addition & 1 deletion src/bub/hooks/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def run_model_stream(self, prompt: str | list[dict], session_id: str, state: Tur
raise NotImplementedError

@hookspec(firstresult=True)
def continue_prompt(self, tape: Tape, state: StreamState) -> str:
def continue_prompt(self, prompt: str | list[dict], tape: Tape, state: StreamState) -> str:
"""Build the prompt used to continue an agent loop.

Implementations may be synchronous or asynchronous. The first
Expand Down
5 changes: 4 additions & 1 deletion tests/test_builtin_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ async def test_agent_loop_awaits_continue_prompt_hook_with_stream_state() -> Non
agent = _make_agent()
tape = _FakeTape(_ForkCapture())
prompts: list[str | list[dict]] = []
continuation_prompts: list[str | list[dict]] = []
observed_usage: list[dict[str, Any] | None] = []

async def run_once(**kwargs: Any) -> AsyncStreamEvents:
Expand All @@ -268,7 +269,8 @@ async def iterator() -> AsyncIterator[StreamEvent]:

return AsyncStreamEvents(iterator(), state=StreamState(usage={"step": len(prompts)}))

async def continue_prompt(*, tape: _FakeTape, state: StreamState) -> str:
async def continue_prompt(*, prompt: str | list[dict], tape: _FakeTape, state: StreamState) -> str:
continuation_prompts.append(prompt)
observed_usage.append(state.usage)
return "custom continuation"

Expand All @@ -286,6 +288,7 @@ async def continue_prompt(*, tape: _FakeTape, state: StreamState) -> str:

assert [event.kind for event in events] == ["final", "final"]
assert prompts == ["initial prompt", "custom continuation"]
assert continuation_prompts == ["initial prompt"]
assert observed_usage == [{"step": 1}]


Expand Down
2 changes: 1 addition & 1 deletion tests/test_builtin_hook_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def test_continue_prompt_includes_tape_context(tmp_path: Path) -> None:
_, impl, _ = _build_impl(tmp_path)
tape = _fake_tape(tmp_path).with_context(TapeContext(state={"context": "telegram metadata"}))

prompt = impl.continue_prompt(tape=tape, state=StreamState())
prompt = impl.continue_prompt(prompt="current prompt", tape=tape, state=StreamState())

assert prompt == f"{DEFAULT_CONTINUE_PROMPT} [context: telegram metadata]"

Expand Down
7 changes: 4 additions & 3 deletions tests/test_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,21 +127,22 @@ async def test_continue_prompt_awaits_high_priority_async_hook() -> None:

class SyncPlugin:
@hookimpl
def continue_prompt(self, tape, state):
def continue_prompt(self, prompt, tape, state):
called.append("sync")
return "sync prompt"

class AsyncPlugin:
@hookimpl
async def continue_prompt(self, tape: Any, state: StreamState) -> str:
async def continue_prompt(self, prompt: str, tape: Any, state: StreamState) -> str:
called.append("async")
assert prompt == "current prompt"
assert state.usage == {"total_tokens": 42}
return "async prompt"

framework._plugin_manager.register(SyncPlugin(), name="sync")
framework._plugin_manager.register(AsyncPlugin(), name="async")

prompt = await framework.continue_prompt(tape=tape, state=state)
prompt = await framework.continue_prompt(prompt="current prompt", tape=tape, state=state)

assert prompt == "async prompt"
assert called == ["async"]
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/docs/reference/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ For the *why* and *how* of each stage see [Turn pipeline](/docs/concepts/turn-pi
| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | Falls back to `content_of(message)` when no impl returns a non-`None` value, or when the selected firstresult is falsy. A falsy selected value does not cause lower-priority impls to run. |
| `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | Legacy text path. Implement either `run_model` or `run_model_stream`, not both. |
| `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | Preferred. Falls back to wrapping a `run_model` result in a one-chunk stream when no streaming impl exists. |
| `continue_prompt` | firstresult | `(tape: Tape, state: StreamState) -> str` | next-step prompt | `Agent._stream_events_with_auto_handoff` | Runs before each continued agent-loop step. Implementations may be sync or async; the builtin preserves the default prompt and optional tape context. |
| `continue_prompt` | firstresult | `(prompt: str \| list[dict], tape: Tape, state: StreamState) -> str` | next-step prompt | `Agent._stream_events_with_auto_handoff` | Runs before each continued agent-loop step. `prompt` is the prompt used by the completed step. Implementations may be sync or async; the builtin preserves the default prompt and optional tape context. |
| `save_state` | broadcast | `(session_id, state, message, model_output) -> None` | none | `process_inbound` model-stage finally block | Runs after prompt resolution for model-stage success or failure; failures before prompt/model execution skip it. |
| `render_outbound` | broadcast | `(message, session_id, state, model_output) -> list[Envelope]` | outbound batch | `BubFramework._collect_outbounds` | All batches are concatenated via `unpack_batch`. Empty results trigger a default echo envelope. |
| `dispatch_outbound` | broadcast | `(message: Envelope) -> bool` | sent flag | `process_inbound` per outbound | Each outbound is fanned out to every impl. |
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/zh-cn/docs/reference/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ description: BubHookSpecs 中每个钩子的类型、签名、返回值与调用
| `build_prompt` | firstresult | `(message: Envelope, session_id: str, state: TurnState) -> str \| list[dict]` | prompt or content parts | `BubFramework.process_inbound` | 没有实现返回非 `None` 值,或选中的 firstresult 为 falsy 时,回退到 `content_of(message)`。已选中的 falsy 值不会触发继续尝试低优先级实现。 |
| `run_model` | firstresult | `(prompt, session_id, state) -> str` | model text | `HookRuntime.run_model` | 旧版纯文本路径。`run_model` 与 `run_model_stream` 选其一实现,不要同时实现。 |
| `run_model_stream` | firstresult | `(prompt, session_id, state) -> AsyncStreamEvents` | async stream | `HookRuntime.run_model_stream` | 推荐路径。无流式实现时框架将 `run_model` 结果包装为单个 chunk 的流。 |
| `continue_prompt` | firstresult | `(tape: Tape, state: StreamState) -> str` | 下一步 prompt | `Agent._stream_events_with_auto_handoff` | 每次 agent loop 继续前调用。实现可同步或异步;builtin 保留默认 prompt 与可选 tape context。 |
| `continue_prompt` | firstresult | `(prompt: str \| list[dict], tape: Tape, state: StreamState) -> str` | 下一步 prompt | `Agent._stream_events_with_auto_handoff` | 每次 agent loop 继续前调用。`prompt` 是刚完成 step 使用的 prompt。实现可同步或异步;builtin 保留默认 prompt 与可选 tape context。 |
| `save_state` | broadcast | `(session_id, state, message, model_output) -> None` | none | `process_inbound` model-stage finally block | prompt 解析完成后,在模型阶段成功或失败时执行;若失败发生在 prompt/model 执行前则跳过。 |
| `render_outbound` | broadcast | `(message, session_id, state, model_output) -> list[Envelope]` | outbound batch | `BubFramework._collect_outbounds` | 所有批次通过 `unpack_batch` 拼接;结果为空时使用默认回声 envelope。 |
| `dispatch_outbound` | broadcast | `(message: Envelope) -> bool` | sent flag | `process_inbound` per outbound | 每个 outbound 都会广播给所有实现。 |
Expand Down
Loading