From 403164e3a2f7e014e8cfdfbabd7866642a1c3646 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Nov 2026 10:28:23 +0800 Subject: [PATCH 1/4] feat(core): fork open tapes and expose step messages and call IDs to tools - `Agent.start`/`run`/`session` accept an open `Tape` as well as a tape name, so a run can start from another run's tape. - `ToolContext.step_messages` holds the user messages the calling model step sent, which the tape records only after the step's tool calls finish. - `ToolContext.call_id` is the model's ID of the call. Co-Authored-By: Claude Opus 5.5 --- src/bub/core/agent.py | 25 +++++++++++++++++-------- src/bub/core/tools.py | 9 ++++++++- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py index 3269f35c..b96e82b7 100644 --- a/src/bub/core/agent.py +++ b/src/bub/core/agent.py @@ -148,13 +148,19 @@ def tape(self, name: str, *, state: TurnState | None = None) -> Tape: @asynccontextmanager async def session( - self, name: str, *, state: TurnState | None = None, merge_back: bool = True + self, name: str | Tape, *, state: TurnState | None = None, merge_back: bool = True ) -> AsyncGenerator[Tape]: """Open a fork of tape ``name`` for one run, with ``state`` as its turn state. + ``name`` may also be an open :class:`Tape`, such as another run's: the fork then + starts from what that tape holds, including entries its own fork has not merged yet. The fork merges back on exit unless ``merge_back`` is false. """ - async with self.tape(name, state=state).fork_tape(merge_back=merge_back) as tape: + if isinstance(name, Tape): + base = name.with_context(replace(name.context, state={} if state is None else state)) + else: + base = self.tape(name, state=state) + async with base.fork_tape(merge_back=merge_back) as tape: await tape.ensure_bootstrap_anchor() yield tape @@ -165,7 +171,7 @@ async def run( *, model: Model | None = None, options: Mapping[str, Any] | None = None, - tape: str, + tape: str | Tape, state: TurnState | None = None, merge_back: bool = True, metadata: dict[str, Any] | None = None, @@ -193,7 +199,7 @@ async def start( *, model: Model | None = None, options: Mapping[str, Any] | None = None, - tape: str, + tape: str | Tape, state: TurnState | None = None, merge_back: bool = True, metadata: dict[str, Any] | None = None, @@ -208,8 +214,9 @@ async def start( ``options`` are ``republic.ChatOptions`` (such as ``max_tokens`` or ``temperature``) sent with every model call of the run. - The run works on a fork of the tape (see :meth:`session`) that stays open until - the stream closes, even if it is never consumed. ``state`` is the run's mutable + ``tape`` is a tape name, or an open tape to fork (see :meth:`session`). The run works + on a fork of the tape that stays open until the stream closes, even if it is never + consumed. ``state`` is the run's mutable turn state, carried by events and tools; ``metadata`` is attached to the ``RunStarted`` event. Each model step ends with a ``final`` event; the run ends when a step makes no tool calls and no messages are pending. A context-length @@ -222,7 +229,7 @@ async def start( { "gen_ai.operation.name": "invoke_agent", "gen_ai.agent.name": self.name, - "gen_ai.conversation.id": (state or {}).get("session_id", tape), + "gen_ai.conversation.id": (state or {}).get("session_id", tape if isinstance(tape, str) else tape.name), }, ) stack = AsyncExitStack() @@ -388,7 +395,9 @@ async def run_once( tool_invocations = [_tool_invocation(tool_call, tool_map) for tool_call in tool_calls] yield StreamEvent("tool_call", {"tool_calls": serialized_tool_calls}) turn_state["_runtime_tool_names"] = tuple(tool_map) - context = ToolContext(tape=tape, run_id=run_id, state=turn_state, events=self.events) + context = ToolContext( + tape=tape, run_id=run_id, state=turn_state, events=self.events, step_messages=new_messages + ) execution = await ToolExecutor(events=self.events, max_concurrency=self.max_tool_concurrency).execute_many( tool_invocations, context=context, diff --git a/src/bub/core/tools.py b/src/bub/core/tools.py index 1caecc8d..6f6f17e1 100644 --- a/src/bub/core/tools.py +++ b/src/bub/core/tools.py @@ -28,7 +28,10 @@ class ToolContext: as a direct model tool call. Whether the result is rendered to text is decided by the ``ToolExecutor`` running the call, not by this flag. ``command`` is true when the tool runs as a command prompt rather than for the model. ``events`` is the bus of the agent - running the call, for tools that execute further tool calls. + running the call, for tools that execute further tool calls. ``step_messages`` are the + user messages the calling model step sent, which the tape records only after its tool + calls finish. ``call_id`` is the model's ID of the call; calls made from code share the + ID of the code call. """ tape: Tape @@ -37,6 +40,8 @@ class ToolContext: code_mode: bool = False command: bool = False events: EventBus | None = field(default=None, repr=False, compare=False) + step_messages: list[republic.Message] = field(default_factory=list, repr=False, compare=False) + call_id: str | None = None def render_result(result: Any) -> str: @@ -364,6 +369,8 @@ async def _trace_tool_response( context: ToolContext | None, call_id: str | None, ) -> Any: + if context is not None and call_id is not None: + context = replace(context, call_id=call_id) async with self._limiter: span = Span( f"execute_tool {tool.name}", From 5cd32a4082b78adfb2c3624206010093ad7c3434 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Nov 2026 10:28:24 +0800 Subject: [PATCH 2/4] feat(builtin): run subagents as tasks with background mode, events and profiles Fixes: - A child run copied the parent's `_runtime_thread_id`, so it drained steering messages meant for the parent. Children now get their own thread ID and drop the parent's tool scope and running tape. - `session="inherit"` merged the child's turn into the parent session out of order. It is now an alias of `fork`: the child starts from a snapshot of the current conversation, including the calling step's messages, and is discarded. - `output` is the child's final answer instead of every text delta. New: - `background=true` returns a task ID at once; the result reaches the parent as a `` steering message unless read with `subagent.wait`. - `subagent.wait`, `subagent.send` and `subagent.cancel` tools. - `SubagentStarted`, `SubagentOutput` and `SubagentFinished` events on the agent's bus, so channels can observe subagents without replacing the tool. - Subagent profiles in `.agents/agents/*.md`. - Child tools and skills stay within the parent's; concurrency limit (`BUB_SUBAGENT_MAX_CONCURRENCY`) and timeout (`BUB_SUBAGENT_TIMEOUT_SECONDS`). - Cancelling the parent call, `quit` or runtime exit cancels subagents; ending a temporary subagent stops its background shells. Co-Authored-By: Claude Opus 5.5 --- src/bub/builtin/agent.py | 29 +- src/bub/builtin/hook_impl.py | 4 +- src/bub/builtin/settings.py | 6 + src/bub/builtin/subagents.py | 824 +++++++++++++++++++++++++++++++++++ src/bub/builtin/tools.py | 70 +-- tests/test_subagent_tool.py | 504 +++++++++++++++++---- 6 files changed, 1292 insertions(+), 145 deletions(-) create mode 100644 src/bub/builtin/subagents.py diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 6c4f5018..9adfad23 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -28,8 +28,9 @@ class BubAgent(core.Agent): Adds commands, skills, code mode and deferred tools to :class:`bub.core.Agent`, and connects the framework's agent-loop hooks through :class:`AgentPlugins`. System prompt - sections, in order: code mode, deferred tools, skills, the framework's ``system_prompt`` - hooks, then sections added with ``system_prompt.add``. The framework's hooks handle + sections, in order: a subagent's profile instructions, code mode, deferred tools, skills, + subagent profiles, the framework's ``system_prompt`` hooks, then sections added with + ``system_prompt.add``. The framework's hooks handle events before handlers added with ``events.on``. One instance serves any number of sessions: per-turn choices (model, allowed tools @@ -73,9 +74,11 @@ def __init__( store=framework.get_tape_store() if tape_store is None else tape_store, tools=() if tools is None else tools, system_prompt=[ + self._subagent_instructions_prompt, self._code_mode_prompt, self._deferred_tools_prompt, self._skills_prompt, + self._subagent_profiles_prompt, ], context=framework.build_tape_context(), sidecars=framework.get_tape_sidecars(), @@ -95,6 +98,7 @@ async def run_stream( allowed_skills: Collection[str] | None = None, allowed_tools: Collection[str] | None = None, reasoning_effort: str | None = None, + fork_from: Tape | None = None, ) -> AsyncStreamEvents: """Prepare a turn and return its stream; await this method before iterating. @@ -114,6 +118,8 @@ async def run_stream( None allows all instance tools and an empty collection allows none. Command execution uses the instance's tools directly. reasoning_effort: Per-turn override of the value in state. + fork_from: An open tape, such as a running turn's, to start from instead of the + session's tape. The turn sees what that tape holds and its fork is discarded. Loads the turn state and starts the turn with :meth:`start` on the session's tape. Consume the stream to completion to finish execution and tape merging. A @@ -142,9 +148,9 @@ async def run_stream( model=self.settings.chat_model( state.get("model") if model is None else model, reasoning_effort=state.get("reasoning_effort") ), - tape=session_tape_name(session_id, workspace_from_state(state)), + tape=session_tape_name(session_id, workspace_from_state(state)) if fork_from is None else fork_from, state=state, - merge_back=not session_id.startswith("temp/"), + merge_back=fork_from is None and not session_id.startswith("temp/"), metadata=metadata, ) @@ -219,6 +225,21 @@ def _deferred_tools_prompt(self, context: core.PromptContext) -> str: tools, _ = self._split_code_mode(context.state) return render_deferred_tools_prompt(_deferred_tools(tools)) + @staticmethod + def _subagent_instructions_prompt(context: core.PromptContext) -> str | None: + from bub.builtin.subagents import SUBAGENT_INSTRUCTIONS_STATE_KEY + + return context.state.get(SUBAGENT_INSTRUCTIONS_STATE_KEY) + + @staticmethod + def _subagent_profiles_prompt(context: core.PromptContext) -> str: + """List subagent profiles when the run may start subagents.""" + from bub.builtin.subagents import SUBAGENT_TOOL_NAME, discover_subagent_profiles, render_profiles_prompt + + if not any(tool.name == SUBAGENT_TOOL_NAME for tool in context.tools): + return "" + return render_profiles_prompt(list(discover_subagent_profiles(workspace_from_state(context.state)).values())) + def _skills_prompt(self, context: core.PromptContext) -> str: allowed = context.state.get(ALLOWED_SKILLS_STATE_KEY) return self._load_skills_prompt( diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index fd7682a4..6798baad 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -263,8 +263,10 @@ def render_outbound( @hookimpl async def provide_lifespan(self) -> AsyncIterator[None]: from bub.builtin.shell_manager import shell_manager + from bub.builtin.subagents import subagent_manager - async with shell_manager.lifespan(): + # Subagents stop before the shells they may have started. + async with shell_manager.lifespan(), subagent_manager.lifespan(): yield @hookimpl diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 961c6658..2c2a4e69 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -83,6 +83,12 @@ class AgentSettings(Settings): providers: dict[str, CustomProvider] = Field(default_factory=dict) max_steps: int = Field(default=sys.maxsize, gt=0) max_tokens: int = DEFAULT_MAX_TOKENS + subagent_max_concurrency: int = Field( + default=4, gt=0, description="Maximum subagents running at once; later ones wait in a queue." + ) + subagent_timeout_seconds: int | None = Field( + default=None, gt=0, description="Default time limit of one subagent run; None means no limit." + ) model_timeout_seconds: int | None = None client_args: dict[str, Any] = Field(default_factory=dict) completion_args: dict[str, Any] = Field(default_factory=dict) diff --git a/src/bub/builtin/subagents.py b/src/bub/builtin/subagents.py new file mode 100644 index 00000000..138adcd1 --- /dev/null +++ b/src/bub/builtin/subagents.py @@ -0,0 +1,824 @@ +"""Subagents: child runs of a Bub agent, in the foreground or as background tasks. + +The ``subagent`` tool starts a child run of the same agent and tracks it as a +:class:`SubagentTask`. A foreground task returns its answer as the tool result; a +background task returns a task ID at once, keeps running, and reports its result to the +parent session as a ```` steering message, unless the parent read +it first with ``subagent.wait``. + +Each task publishes :class:`SubagentStarted`, :class:`SubagentOutput` (one per child +stream event) and :class:`SubagentFinished` on the agent's event bus, so channels can +show child runs without replacing the tool. + +Subagent profiles are markdown files with YAML front matter in ``.agents/agents/`` +(workspace, then home): the front matter sets ``description``, and optionally +``model``, ``tools``, ``skills``, ``reasoning_effort`` and ``timeout_seconds``; the body +is added to the child's system prompt. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +import uuid +from collections.abc import AsyncIterator, Collection, Iterable +from contextlib import aclosing +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast + +import republic +import yaml +from loguru import logger +from pydantic import BaseModel, Field + +from bub.core.events import AgentEvent, EventBus +from bub.core.tools import ToolContext, tool +from bub.streaming import StreamEvent +from bub.turn import TurnState + +if TYPE_CHECKING: + from bub.builtin.agent import BubAgent + from bub.core.tape import Tape + +SubagentStatus = Literal["queued", "running", "completed", "failed", "cancelled"] +TERMINAL_STATUSES: tuple[SubagentStatus, ...] = ("completed", "failed", "cancelled") + +SUBAGENT_TOOL_NAME = "subagent" +SUBAGENT_THREAD_PREFIX = "subagent:" +"""Prefix of a child run's ``_runtime_thread_id``, which keys its steering messages.""" +SUBAGENT_CHANNEL = "subagent" +SUBAGENT_INSTRUCTIONS_STATE_KEY = "_runtime_subagent_instructions" +"""A child run's profile instructions, added to its system prompt.""" +PROFILES_DIR = ".agents/agents" +DEFAULT_WAIT_TIMEOUT_SECONDS = 60 +MAX_RETAINED_TASKS = 200 + +_PARENT_ONLY_STATE_KEYS = frozenset({ + # Keys the steering inbox, the parent's tool scope and its running tape; a child gets its own. + "_runtime_thread_id", + "_runtime_allowed_tools", + "_runtime_tool_names", + "_runtime_code_tools", + "_runtime_tape", + "allowed_skills", + SUBAGENT_INSTRUCTIONS_STATE_KEY, +}) + + +class SubAgentInput(BaseModel): + prompt: str | list[dict] = Field( + ..., description="The task for the sub-agent, either as a string or a list of message parts." + ) + agent: str | None = Field( + None, description="Name of a subagent profile to use; its settings apply unless given here." + ) + description: str | None = Field(None, description="A short label for the task, shown in task lists.") + model: str | None = Field(None, description="The model to use for the sub-agent.") + reasoning_effort: str | None = Field(None, description="The reasoning effort of the sub-agent's model.") + session: str = Field( + "temp", + description=( + "'temp' (default): a new empty conversation that is discarded afterwards. " + "'fork': starts from a copy of the current conversation, discarded afterwards. " + "Any other value names a session that is kept and can be continued later." + ), + ) + allowed_tools: list[str] | None = Field( + None, + description="Tool names the sub-agent may use; defaults to the tools available to the current agent.", + ) + allowed_skills: list[str] | None = Field( + None, + description="Skill names the sub-agent may use; defaults to the skills available to the current agent.", + ) + background: bool = Field( + False, + description=( + "Run in the background and return a task ID at once. The result arrives later as a " + " message; use subagent.wait, subagent.send and subagent.cancel " + "to manage the task." + ), + ) + timeout_seconds: int | None = Field(None, gt=0, description="Stop the sub-agent after this many seconds.") + + +class SubAgentResult(TypedDict): + task_id: str + session_id: str + status: SubagentStatus + output: str + errors: list[str] + + +@dataclass(frozen=True) +class SubagentProfile: + """A named subagent configuration loaded from a markdown file.""" + + name: str + description: str + instructions: str + location: Path + model: str | None = None + tools: list[str] | None = None + skills: list[str] | None = None + reasoning_effort: str | None = None + timeout_seconds: int | None = None + + +@dataclass +class _RunRequest: + """How to run a task's child: everything but its prompt, kept to continue the session.""" + + agent: BubAgent + session: str + fork_from: Tape | None + model: str | None + reasoning_effort: str | None + allowed_tools: set[str] + allowed_skills: list[str] | None + instructions: str | None + timeout_seconds: int | None + base_state: TurnState + + +@dataclass(eq=False) +class SubagentTask: + """One subagent run and its outcome. + + ``persistent`` tasks run on a named session that outlives them, so ``subagent.send`` + can continue it after the task ends. + """ + + task_id: str + session_id: str + parent_session_id: str + description: str + model: str | None + profile: str | None + background: bool + persistent: bool + parent_call_id: str | None = None + """The ID of the tool call that started the task, when the model made it.""" + status: SubagentStatus = "queued" + output: str = "" + errors: list[str] = field(default_factory=list) + usage: dict[str, Any] | None = None + steps: int = 0 + tool_calls: int = 0 + last_tool: str | None = None + created_at: float = field(default_factory=time.time) + started_at: float | None = None + ended_at: float | None = None + delivered: bool = False + """Whether the parent has read the result; a delivered result sends no notification.""" + _parent_key: TurnState = field(default_factory=dict, repr=False) + _request: _RunRequest | None = field(default=None, repr=False) + _events: EventBus | None = field(default=None, repr=False) + _parent_state: TurnState = field(default_factory=dict, repr=False) + _runner: asyncio.Task[None] | None = field(default=None, repr=False) + _done: asyncio.Event = field(default_factory=asyncio.Event, repr=False) + _waiters: int = field(default=0, repr=False) + _pending_text: list[str] = field(default_factory=list, repr=False) + + @property + def done(self) -> bool: + return self.status in TERMINAL_STATUSES + + @property + def thread_id(self) -> str: + """The child run's ``_runtime_thread_id``, keying the messages ``subagent.send`` steers into it.""" + return f"{SUBAGENT_THREAD_PREFIX}{self.task_id}" + + def result(self) -> SubAgentResult: + return { + "task_id": self.task_id, + "session_id": self.session_id, + "status": self.status, + "output": self.output, + "errors": list(self.errors), + } + + def observe(self, event: StreamEvent) -> None: + """Track the child stream: the answer is the text of the last step that made no tool calls.""" + if event.kind == "text": + self._pending_text.append(str(event.data.get("delta", ""))) + elif event.kind == "tool_call": + calls = event.data.get("tool_calls") or [] + self.tool_calls += len(calls) + if calls and isinstance(calls[-1], dict): + self.last_tool = str(calls[-1].get("name") or (calls[-1].get("function") or {}).get("name") or "") + elif event.kind == "error": + self.errors.append(str(event.data.get("message") or event.data.get("error") or "unknown error")) + elif event.kind == "final": + self.steps += 1 + if not (event.data.get("tool_calls") or event.data.get("tool_results")): + self.output = "".join(self._pending_text) + self._pending_text.clear() + + def finish_output(self) -> None: + """Fall back to the text of an unfinished last step when no step answered.""" + if not self.output and self._pending_text: + self.output = "".join(self._pending_text) + self._pending_text.clear() + + async def wait(self) -> None: + await self._done.wait() + + +@dataclass(kw_only=True) +class SubagentEvent(AgentEvent): + """A subagent task changed; ``state`` is the turn state of the run that started it.""" + + task: SubagentTask + + +@dataclass(kw_only=True) +class SubagentStarted(SubagentEvent): + """A subagent task left the queue and its child run is starting.""" + + +@dataclass(kw_only=True) +class SubagentOutput(SubagentEvent): + """The child run produced ``event``; ``task`` already reflects it.""" + + event: StreamEvent + + +@dataclass(kw_only=True) +class SubagentFinished(SubagentEvent): + """A subagent task ended as ``completed``, ``failed`` or ``cancelled``.""" + + +class SubagentManager: + """Own subagent tasks across turns, limit how many run at once, and stop them on exit.""" + + def __init__(self) -> None: + self._tasks: dict[str, SubagentTask] = {} + self._slots: asyncio.Semaphore | None = None + self._slots_loop: asyncio.AbstractEventLoop | None = None + + @contextlib.asynccontextmanager + async def lifespan(self) -> AsyncIterator[None]: + """Cancel the subagent tasks still running when this runtime exits.""" + try: + yield + finally: + await self._cancel_tasks([task for task in self._tasks.values() if not task.done]) + + def get(self, task_id: str) -> SubagentTask: + try: + return self._tasks[task_id.strip()] + except KeyError: + raise ValueError(f"unknown subagent task: {task_id}") from None + + def tasks(self, parent_session_id: str | None = None) -> list[SubagentTask]: + """Return tasks, oldest first, optionally only those started by one session.""" + return [ + task + for task in self._tasks.values() + if parent_session_id is None or task.parent_session_id == parent_session_id + ] + + async def start( + self, + request: _RunRequest, + prompt: str | list[dict], + *, + events: EventBus | None, + parent_state: TurnState, + description: str | None, + profile: str | None, + background: bool, + parent_call_id: str | None = None, + ) -> SubagentTask: + """Queue a child run and return its task; the run starts once a slot is free.""" + task_id = f"sub-{uuid.uuid4().hex[:8]}" + persistent = request.session not in ("temp", "fork") + parent_session_id = str(parent_state.get("session_id") or "") + task = SubagentTask( + task_id=task_id, + session_id=request.session if persistent else f"temp/{task_id}", + parent_session_id=parent_session_id, + description=description or _describe(prompt), + model=request.model, + profile=profile, + background=background, + persistent=persistent, + parent_call_id=parent_call_id, + _parent_key=_steering_key(parent_state), + _request=request, + _events=events, + _parent_state=parent_state, + ) + self._prune() + self._tasks[task_id] = task + task._runner = asyncio.create_task(self._run(task, prompt), name=f"subagent {task_id}") + return task + + async def wait(self, tasks: Iterable[SubagentTask], timeout: float | None) -> None: + """Wait until every task ends or ``timeout`` seconds pass; their results count as read.""" + tasks = list(tasks) + for task in tasks: + task._waiters += 1 + try: + async with asyncio.timeout(timeout): + await asyncio.gather(*(task.wait() for task in tasks)) + except TimeoutError: + pass + finally: + for task in tasks: + task._waiters -= 1 + if task.done: + task.delivered = True + + async def cancel(self, task_id: str) -> SubagentTask: + task = self.get(task_id) + await self._cancel_tasks([task]) + return task + + async def cancel_session(self, parent_session_id: str) -> int: + """Cancel the running tasks a session started; return how many.""" + tasks = [task for task in self.tasks(parent_session_id) if not task.done] + await self._cancel_tasks(tasks) + return len(tasks) + + async def steer(self, task: SubagentTask, message: str) -> bool: + """Queue ``message`` for a running task's next step; False if it cannot be delivered.""" + if task.done or task._request is None: + return False + framework = getattr(task._request.agent, "framework", None) + if framework is None: + return False + return bool( + await framework.steer_message( + message=_channel_message(task.session_id, task.task_id, message), + session_id=task.session_id, + state={"session_id": task.session_id, "_runtime_thread_id": task.thread_id}, + ) + ) + + async def _cancel_tasks(self, tasks: list[SubagentTask]) -> None: + runners = [task._runner for task in tasks if task._runner is not None and not task._runner.done()] + for runner in runners: + runner.cancel() + await asyncio.gather(*runners, return_exceptions=True) + for task in tasks: + if not task.done: + # Cancelled before its runner started: the run body, and its cleanup, never ran. + task.status = "cancelled" + task.ended_at = time.time() + await self._settle(task) + + def _prune(self) -> None: + """Forget the oldest read results beyond ``MAX_RETAINED_TASKS``.""" + finished = [task for task in self._tasks.values() if task.done and task.delivered] + for task in finished[: max(0, len(self._tasks) - MAX_RETAINED_TASKS + 1)]: + self._tasks.pop(task.task_id, None) + + @contextlib.asynccontextmanager + async def _slot(self) -> AsyncIterator[None]: + from bub.builtin.settings import load_settings + + loop = asyncio.get_running_loop() + if self._slots is None or self._slots_loop is not loop: + self._slots = asyncio.Semaphore(load_settings().subagent_max_concurrency) + self._slots_loop = loop + async with self._slots: + yield + + async def _run(self, task: SubagentTask, prompt: str | list[dict]) -> None: + request = cast("_RunRequest", task._request) + events, parent_state = task._events, task._parent_state + try: + async with self._slot(): + task.status = "running" + task.started_at = time.time() + await _publish(events, SubagentStarted(state=parent_state, task=task)) + async with asyncio.timeout(request.timeout_seconds): + stream = await request.agent.run_stream( + session_id=task.session_id, + prompt=prompt, + state=_child_state(request, task), + model=request.model, + allowed_tools=request.allowed_tools, + allowed_skills=request.allowed_skills, + reasoning_effort=request.reasoning_effort, + fork_from=request.fork_from, + ) + async with aclosing(stream): + async for event in stream: + task.observe(event) + await _publish(events, SubagentOutput(state=parent_state, task=task, event=event)) + task.usage = getattr(stream, "usage", None) + task.finish_output() + task.status = "failed" if task.errors and not task.output else "completed" + except asyncio.CancelledError: + task.finish_output() + task.status = "cancelled" + raise + except TimeoutError: + task.finish_output() + task.status = "failed" + task.errors.append(f"timed out after {request.timeout_seconds} seconds") + except Exception as exc: + logger.opt(exception=exc).warning("subagent.failed task_id={}", task.task_id) + task.finish_output() + task.status = "failed" + task.errors.append(f"{exc!s}" or type(exc).__name__) + finally: + task.ended_at = time.time() + await self._settle(task) + + async def _settle(self, task: SubagentTask) -> None: + """Clean up a finished task, publish its end and notify the parent of an unread result.""" + request = cast("_RunRequest", task._request) + events, parent_state = task._events, task._parent_state + framework = getattr(request.agent, "framework", None) + inbox = framework.get_steering_inbox() if framework is not None else None + child_key = {"session_id": task.session_id, "_runtime_thread_id": task.thread_id} + if inbox is not None and (undelivered := await inbox.drain_messages(child_key)): + task.errors.append(f"{len(undelivered)} message(s) sent to the task arrived after it finished") + if not task.persistent: + from bub.builtin.shell_manager import shell_manager + + # Nobody can reach a temporary session's background shells once its run ends. + await shell_manager.terminate_session(task.session_id) + task._done.set() + await _publish(events, SubagentFinished(state=parent_state, task=task)) + if task.background and not task.delivered and task._waiters == 0 and framework is not None: + await framework.steer_message( + message=_channel_message(task.parent_session_id, task.task_id, render_notification(task)), + session_id=task.parent_session_id, + state=dict(task._parent_key), + ) + + +subagent_manager = SubagentManager() + + +async def _publish(events: EventBus | None, event: SubagentEvent) -> None: + """Deliver an observation event; a failing handler must not fail the child run.""" + if events is None: + return + try: + await events.emit(event) + except Exception: + logger.exception("subagent.event_handler_failed event={}", type(event).__name__) + + +def _steering_key(state: TurnState) -> TurnState: + key: TurnState = {"session_id": state.get("session_id")} + if thread_id := state.get("_runtime_thread_id"): + key["_runtime_thread_id"] = thread_id + return key + + +def _child_state(request: _RunRequest, task: SubagentTask) -> TurnState: + state = {key: value for key, value in request.base_state.items() if key not in _PARENT_ONLY_STATE_KEYS} + state["session_id"] = task.session_id + state["_runtime_thread_id"] = task.thread_id + if request.instructions: + state[SUBAGENT_INSTRUCTIONS_STATE_KEY] = request.instructions + return state + + +def _channel_message(session_id: str, task_id: str, content: str) -> Any: + from bub.channels.message import ChannelMessage + + return ChannelMessage(session_id=session_id, channel=SUBAGENT_CHANNEL, chat_id=task_id, content=content) + + +def _describe(prompt: str | list[dict]) -> str: + if isinstance(prompt, str): + text = prompt + else: + text = next((str(part.get("text", "")) for part in prompt if part.get("type") == "text"), "") + first_line = next((line.strip() for line in text.strip().splitlines() if line.strip()), "") + return first_line[:120] + + +def render_notification(task: SubagentTask) -> str: + """The message telling the parent a background task ended.""" + body = task.output.strip() or "(no output)" + errors = "".join(f"\n[Error: {message}]" for message in task.errors) + return ( + f'\n' + f"{body}{errors}\n" + ) + + +def _render_result(result: SubAgentResult | str) -> str: + if isinstance(result, str): + return result + errors = "".join(f"[Error: {message}]" for message in result["errors"]) + if result["status"] in ("queued", "running"): + return ( + f"Subagent task {result['task_id']} started in the background (session {result['session_id']}). " + "Its result arrives as a message; use subagent.wait to check or block " + "on it, subagent.send to message it, or subagent.cancel to stop it." + ) + if result["status"] == "cancelled": + partial = f"\nPartial output:\n{result['output']}" if result["output"] else "" + return f"Subagent task {result['task_id']} was cancelled.{partial}" + return result["output"] + errors + + +def _render_task_list(results: list[SubAgentResult]) -> str: + if not results: + return "(no subagent tasks)" + return "\n\n".join( + f"[{result['task_id']}] status: {result['status']} session: {result['session_id']}" + + (f"\n{_render_result(result)}" if result["status"] in TERMINAL_STATUSES else "") + for result in results + ) + + +# ---------------------------------------------------------------- profiles + + +def discover_subagent_profiles(workspace: Path) -> dict[str, SubagentProfile]: + """Load subagent profiles from the workspace's, then the home directory's ``.agents/agents``. + + A workspace profile overrides a home profile of the same (case-insensitive) name. + """ + profiles: dict[str, SubagentProfile] = {} + for root in (workspace / PROFILES_DIR, Path.home() / PROFILES_DIR): + if not root.is_dir(): + continue + for path in sorted(root.glob("*.md")): + profile = _read_profile(path) + if profile is not None: + profiles.setdefault(profile.name.casefold(), profile) + return profiles + + +def _read_profile(path: Path) -> SubagentProfile | None: + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return None + end = next((index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---"), None) + if end is None: + return None + try: + meta = yaml.safe_load("\n".join(lines[1:end])) + except yaml.YAMLError: + logger.warning("subagent.profile_invalid path={}", path) + return None + if not isinstance(meta, dict) or not isinstance(meta.get("description"), str): + logger.warning("subagent.profile_invalid path={} reason=missing description", path) + return None + timeout = meta.get("timeout_seconds") + return SubagentProfile( + name=str(meta.get("name") or path.stem).strip(), + description=meta["description"].strip(), + instructions="\n".join(lines[end + 1 :]).strip(), + location=path.resolve(), + model=_optional_str(meta.get("model")), + tools=_optional_names(meta.get("tools")), + skills=_optional_names(meta.get("skills")), + reasoning_effort=_optional_str(meta.get("reasoning_effort")), + timeout_seconds=timeout if isinstance(timeout, int) and timeout > 0 else None, + ) + + +def _optional_str(value: object) -> str | None: + return str(value).strip() or None if value is not None else None + + +def _optional_names(value: object) -> list[str] | None: + if value is None: + return None + if isinstance(value, str): + return [name.strip() for name in value.split(",") if name.strip()] + if isinstance(value, list): + return [str(name).strip() for name in value if str(name).strip()] + return None + + +def render_profiles_prompt(profiles: Collection[SubagentProfile]) -> str: + if not profiles: + return "" + lines = "\n".join(f"- {profile.name}: {profile.description}" for profile in profiles) + return f"\nPass one as `agent` to the subagent tool.\n{lines}\n" + + +# ---------------------------------------------------------------- tools + + +def _get_agent(context: ToolContext) -> BubAgent: + if "_runtime_agent" not in context.state: + raise RuntimeError("no runtime agent found in tool context") + return cast("BubAgent", context.state["_runtime_agent"]) + + +def _child_tool_names(agent: BubAgent, state: TurnState, requested: list[str] | None) -> set[str]: + """Resolve the child's tools within the parent's: a child never gets a tool its parent lacks.""" + from bub.builtin.tools import ALLOWED_TOOLS_STATE_KEY, resolve_tool_names + + parent_allowed = state.get(ALLOWED_TOOLS_STATE_KEY) + available = [name for name in agent.tools if parent_allowed is None or name in parent_allowed] + # Children cannot start or manage subagents. + excluded = [name for name in available if name == SUBAGENT_TOOL_NAME or name.startswith("subagent.")] + return resolve_tool_names(requested or None, exclude=excluded, all_names=available) + + +def _child_skill_names(state: TurnState, requested: list[str] | None) -> list[str] | None: + parent_allowed = state.get("allowed_skills") + if requested is None: + return None if parent_allowed is None else list(parent_allowed) + names = [name.casefold() for name in requested] + return names if parent_allowed is None else [name for name in names if name in parent_allowed] + + +async def _resolve_session(param_session: str, context: ToolContext) -> tuple[str, Tape | None]: + session = param_session.strip() or "temp" + if session not in ("fork", "inherit"): + return session, None + if context.tape is None: + return "temp", None + return "fork", await snapshot_tape(context.tape, context.step_messages) + + +async def snapshot_tape(tape: Tape, step_messages: Iterable[republic.Message] = ()) -> Tape: + """Copy ``tape`` and its sidecar tapes into a private store, then add ``step_messages``. + + A forked child starts from the conversation as it is now, including the messages of the + model step that called it, and does not see what the parent records later. + """ + from bub.core.sidecars import sidecar_tape_name + from bub.core.store import AsyncTapeStoreAdapter, InMemoryTapeStore + from bub.core.tape import TapeEntry + + store = InMemoryTapeStore() + for name in (tape.name, *(sidecar_tape_name(tape.name, sidecar.name) for sidecar in tape.sidecars)): + for entry in await tape.store.fetch_all(tape.scoped(name).query()): + store.append(name, entry.copy()) + for message in step_messages: + store.append(tape.name, TapeEntry.message(message.to_dict())) + return replace(tape, store=AsyncTapeStoreAdapter(store)) + + +async def _build_request(param: SubAgentInput, context: ToolContext) -> tuple[_RunRequest, SubagentProfile | None]: + from bub.builtin.settings import load_settings + from bub.utils import workspace_from_state + + agent = _get_agent(context) + state = context.state + profile: SubagentProfile | None = None + if param.agent: + profiles = discover_subagent_profiles(workspace_from_state(state)) + profile = profiles.get(param.agent.strip().casefold()) + if profile is None: + known = ", ".join(sorted(item.name for item in profiles.values())) or "none" + raise ValueError(f"unknown subagent profile {param.agent!r}; available: {known}") + session, fork_from = await _resolve_session(param.session, context) + return ( + _RunRequest( + agent=agent, + session=session, + fork_from=fork_from, + model=param.model or (profile.model if profile else None), + reasoning_effort=param.reasoning_effort or (profile.reasoning_effort if profile else None), + allowed_tools=_child_tool_names(agent, state, param.allowed_tools or (profile.tools if profile else None)), + allowed_skills=_child_skill_names( + state, param.allowed_skills if param.allowed_skills is not None else profile.skills if profile else None + ), + instructions=profile.instructions if profile else None, + timeout_seconds=param.timeout_seconds + or (profile.timeout_seconds if profile else None) + or load_settings().subagent_timeout_seconds, + base_state=dict(state), + ), + profile, + ) + + +async def _start_and_maybe_wait( + request: _RunRequest, + prompt: str | list[dict], + *, + context: ToolContext, + description: str | None, + profile: str | None, + background: bool, +) -> SubAgentResult: + task = await subagent_manager.start( + request, + prompt, + events=getattr(context, "events", None), + parent_state=context.state, + description=description, + profile=profile, + background=background, + parent_call_id=getattr(context, "call_id", None), + ) + if background: + return task.result() + task.delivered = True + try: + await task.wait() + except asyncio.CancelledError: + # The parent's tool call was cancelled: its foreground child goes with it. + await subagent_manager.cancel(task.task_id) + raise + if task.status == "failed" and not task.output: + raise RuntimeError(f"subagent {task.task_id} failed: {'; '.join(task.errors) or 'unknown error'}") + return task.result() + + +@tool(name=SUBAGENT_TOOL_NAME, context=True, model=SubAgentInput, renderer=_render_result) +async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> SubAgentResult: + """Delegate a task to a sub-agent that runs its own loop with this agent's tools and returns its answer. + + The sub-agent only sees `prompt` (and, with session='fork', the current conversation), so make + the prompt self-contained. Set background=true to keep working while it runs. + """ + request, profile = await _build_request(param, context) + return await _start_and_maybe_wait( + request, + param.prompt, + context=context, + description=param.description, + profile=profile.name if profile else None, + background=param.background, + ) + + +@tool(name="subagent.wait", context=True, renderer=_render_task_list) +async def subagent_wait( + task_ids: list[str] | None = None, + timeout_seconds: int = DEFAULT_WAIT_TIMEOUT_SECONDS, + *, + context: ToolContext, +) -> list[SubAgentResult]: + """Return subagent task results, waiting up to timeout_seconds for them to finish (0 checks without waiting). + + Without task_ids, covers this session's tasks whose results were not read yet. + """ + if task_ids: + tasks = [subagent_manager.get(task_id) for task_id in task_ids] + else: + session_id = str(context.state.get("session_id") or "") + tasks = [task for task in subagent_manager.tasks(session_id) if not task.delivered] + await subagent_manager.wait(tasks, max(0, timeout_seconds)) + return [task.result() for task in tasks] + + +@tool(name="subagent.cancel", context=True, renderer=_render_task_list) +async def subagent_cancel(task_id: str, *, context: ToolContext) -> list[SubAgentResult]: + """Stop a running subagent task.""" + task = await subagent_manager.cancel(task_id) + task.delivered = True + return [task.result()] + + +@tool(name="subagent.send", context=True, renderer=_render_result) +async def subagent_send( + task_id: str, message: str, background: bool = False, *, context: ToolContext +) -> SubAgentResult | str: + """Send a message to a subagent task. + + A running task receives it before its next step. A finished task with a named session + continues that conversation as a new task, in the background when background=true. + """ + task = subagent_manager.get(task_id) + if not task.done: + if not await subagent_manager.steer(task, message): + raise RuntimeError(f"cannot deliver messages to subagent task {task.task_id}") + return f"Message queued for subagent task {task.task_id}; it reads the message before its next step." + if not task.persistent or task._request is None: + raise ValueError( + f"subagent task {task.task_id} has finished and its temporary session is gone; " + "start a new subagent, and give it a named `session` to keep a conversation you can continue" + ) + request = task._request + # Continue the named session under the current turn: its tools and skills bound the child again. + continued = _RunRequest( + agent=request.agent, + session=request.session, + fork_from=None, + model=request.model, + reasoning_effort=request.reasoning_effort, + allowed_tools=request.allowed_tools & _child_tool_names(request.agent, context.state, None), + allowed_skills=request.allowed_skills, + instructions=request.instructions, + timeout_seconds=request.timeout_seconds, + base_state=dict(context.state), + ) + return await _start_and_maybe_wait( + continued, + message, + context=context, + description=task.description, + profile=task.profile, + background=background, + ) + + +SUBAGENT_TOOLS = (run_subagent, subagent_wait, subagent_cancel, subagent_send) diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 07cb95e2..80bf2e1e 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -3,9 +3,7 @@ import asyncio import contextlib import json -import uuid from collections.abc import Iterable, Mapping -from contextlib import aclosing from dataclasses import asdict from pathlib import PurePath from typing import TYPE_CHECKING, Any, TypedDict, cast, final @@ -16,6 +14,10 @@ from bub.builtin.environment import environment_from_state from bub.builtin.settings import load_settings, set_session_setting from bub.builtin.shell_manager import shell_manager +from bub.builtin.subagents import SUBAGENT_TOOLS, subagent_manager +from bub.builtin.subagents import SubAgentInput as SubAgentInput +from bub.builtin.subagents import SubAgentResult as SubAgentResult +from bub.builtin.subagents import run_subagent as run_subagent from bub.core.tools import Tool, ToolContext, content_result, tool from bub.skills import discover_skills @@ -162,25 +164,6 @@ class SearchInput(BaseModel): ) -class SubAgentInput(BaseModel): - prompt: str | list[dict] = Field( - ..., description="The initial prompt for the sub-agent, either as a string or a list of message parts." - ) - model: str | None = Field(None, description="The model to use for the sub-agent.") - session: str = Field( - "temp", - description="The session handling strategy for the sub-agent. 'inherit' to use the same session, 'temp' to create a temporary session.", - ) - allowed_tools: list[str] | None = Field( - None, - description="Optional list of allowed tool names for the sub-agent. If not specified, the sub-agent can use any tool available to the main agent.", - ) - allowed_skills: list[str] | None = Field( - None, - description="Optional list of allowed skill names for the sub-agent. If not specified, the sub-agent can use any skill available to the main agent.", - ) - - @final class SkillList(TypedDict): skills: list[str] @@ -240,12 +223,6 @@ class WebFetchResult(TypedDict): content: str -class SubAgentResult(TypedDict): - session_id: str - output: str - errors: list[str] - - def _render_skill(result: SkillList | SkillContent | ToolFailure) -> str: if "error" in result: return f"({result['error']})" @@ -281,10 +258,6 @@ def _render_anchors(result: dict[str, list[str]]) -> str: return "\n".join(f"- {name}" for name in result["anchors"]) -def _render_subagent(result: SubAgentResult) -> str: - return result["output"] + "".join(f"[Error: {message}]" for message in result["errors"]) - - @tool(context=True, exposure="direct") async def bash( command: str, @@ -531,38 +504,6 @@ async def web_fetch(url: str, headers: dict | None = None, timeout: int | None = } -@tool(name="subagent", context=True, model=SubAgentInput, renderer=_render_subagent) -async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> SubAgentResult: - """Run a task with sub-agent using specific model and session.""" - agent = _get_agent(context) - session_id = context.state.get("session_id", "temp/unknown") - if param.session == "inherit": - subagent_session = session_id - elif param.session == "temp": - subagent_session = f"temp/{uuid.uuid4().hex[:8]}" - else: - subagent_session = param.session - state = {**context.state, "session_id": subagent_session} - allowed_tools = resolve_tool_names(param.allowed_tools or None, exclude={"subagent"}, all_names=agent.tools) - output = "" - errors: list[str] = [] - stream = await agent.run_stream( - session_id=subagent_session, - prompt=param.prompt, - state=state, - model=param.model, - allowed_tools=allowed_tools, - allowed_skills=param.allowed_skills, - ) - async with aclosing(stream): - async for event in stream: - if event.kind == "error": - errors.append(str(event.data.get("message", "unknown error"))) - elif event.kind == "text": - output += str(event.data.get("delta", "")) - return {"session_id": subagent_session, "output": output, "errors": errors} - - @tool(name="help", context=True, exposure="command") def show_help(*, context: ToolContext | None = None) -> str: """Show a help message.""" @@ -594,6 +535,7 @@ async def quit_tool(*, context: ToolContext) -> str: """Abort the tasks of the current session. DO NOT use it in a normal workflow.""" agent = _get_agent(context) session_id = str(context.state.get("session_id", "temp/unknown")) + await subagent_manager.cancel_session(session_id) await shell_manager.terminate_session(session_id) await agent.framework.quit_via_channel_router(session_id) return "Session tasks stopped." @@ -637,7 +579,7 @@ async def set_reasoning_effort(reasoning_effort: str, *, context: ToolContext) - tape_handoff, tape_anchors, web_fetch, - run_subagent, + *SUBAGENT_TOOLS, show_help, quit_tool, set_model, diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index fec875f2..451c65c7 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -1,151 +1,503 @@ from __future__ import annotations +import asyncio +import json +from collections.abc import AsyncIterator +from pathlib import Path from typing import Any -from unittest.mock import AsyncMock import pytest - -from bub.builtin.tools import run_subagent -from bub.core.tools import Tool, tool +import pytest_asyncio + +from bub.builtin.agent import BubAgent +from bub.builtin.settings import AgentSettings +from bub.builtin.subagents import ( + SUBAGENT_TOOLS, + SubagentEvent, + SubagentFinished, + SubagentOutput, + SubagentStarted, + run_subagent, + subagent_cancel, + subagent_manager, + subagent_send, + subagent_wait, +) +from bub.builtin.tools import ALLOWED_TOOLS_STATE_KEY +from bub.core.events import ToolCallStarted +from bub.core.store import InMemoryTapeStore +from bub.core.tape import session_tape_name +from bub.core.tools import Tool, ToolContext, tool +from bub.framework import BubFramework from bub.streaming import AsyncStreamEvents, StreamEvent -from tests.tool_fakes import builtin_tools +from tests.model_fakes import ProviderService + +PARENT_KEY = {"session_id": "parent", "_runtime_thread_id": "thread-1"} + + +def call(name: str, call_id: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: + return {"id": call_id, "name": name, "arguments": json.dumps(arguments or {})} + + +@tool(name="tests.lookup") +def lookup() -> str: + """Look something up.""" + return "found" + + +@tool(name="tests.other") +def other() -> str: + """Another tool.""" + return "other" class FakeContext: - """Minimal ToolContext stand-in for testing.""" + """Minimal ToolContext stand-in for argument plumbing tests.""" def __init__(self, state: dict[str, Any]) -> None: self.state = state self.tape = None + self.events = None + self.step_messages: list[Any] = [] class FakeAgent: - def __init__(self, *tools: Tool) -> None: - self.tools = {item.name: item for item in (*builtin_tools(), *tools)} - self.run_stream = AsyncMock(side_effect=self._run_stream) + """Records child runs; ``block`` holds each run until ``release`` is set.""" + + def __init__(self, *tools: Tool, block: bool = False) -> None: + self.tools = {item.name: item for item in (lookup, other, *SUBAGENT_TOOLS, *tools)} + self.calls: list[dict[str, Any]] = [] + self.block = block + self.release = asyncio.Event() - async def _run_stream(self, **kwargs: Any) -> AsyncStreamEvents: - async def iterator(): + async def run_stream(self, **kwargs: Any) -> AsyncStreamEvents: + self.calls.append(kwargs) + + async def iterator() -> AsyncIterator[StreamEvent]: + if self.block: + await self.release.wait() yield StreamEvent("text", {"delta": "agent result"}) + yield StreamEvent("final", {"ok": True, "text": "agent result"}) return AsyncStreamEvents(iterator()) +@pytest_asyncio.fixture +async def runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider_transport: ProviderService +) -> AsyncIterator[BubAgent]: + monkeypatch.setenv("BUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("HOME", str(tmp_path / "user")) + framework = BubFramework(config_file=tmp_path / "config.yml") + framework.workspace = tmp_path + framework.load_builtin_hooks() + async with framework.running(): + agent = BubAgent( + framework, tools=[lookup, other, *SUBAGENT_TOOLS], tape_store=InMemoryTapeStore(), skill_dirs=[] + ) + agent.settings = AgentSettings(model="openai:test", api_key="unused", client_args={"api_format": "chat"}) + yield agent + + +def parent_context(agent: BubAgent, **state: Any) -> ToolContext: + workspace = agent.framework.workspace + return ToolContext( + tape=agent.tape(session_tape_name("parent", workspace)), + state={"_runtime_agent": agent, "_runtime_workspace": str(workspace), **PARENT_KEY, **state}, + events=agent.events, + ) + + +async def run_parent(agent: BubAgent, prompt: str) -> None: + state = {"_runtime_workspace": str(agent.framework.workspace), "_runtime_thread_id": "thread-1"} + stream = await agent.run_stream(session_id="parent", prompt=prompt, state=state) + async for _ in stream: + pass + + +def request_text(provider: ProviderService, index: int) -> str: + return json.dumps(provider.body(index)) + + +def system_prompt(provider: ProviderService, index: int) -> str: + return provider.body(index)["messages"][0]["content"] + + @pytest.mark.asyncio -async def test_subagent_inherit_session() -> None: +async def test_subagent_leaves_parent_steering_messages_to_the_parent( + runtime: BubAgent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat(calls=[call("subagent", "c1", {"prompt": "child task"})]) + provider_transport.reply_chat("child answer") + provider_transport.reply_chat("parent answer") + + @runtime.events.on(ToolCallStarted) + async def steer_parent(event: ToolCallStarted) -> None: + if event.call.tool == "subagent": + await runtime.framework.steer_message( + message={"content": "user follow-up", "session_id": "parent"}, + session_id="parent", + state=dict(PARENT_KEY), + ) + + await run_parent(runtime, "delegate") + + assert "child task" in request_text(provider_transport, 1) + assert "user follow-up" not in request_text(provider_transport, 1) + assert "user follow-up" in request_text(provider_transport, 2) + + +@pytest.mark.asyncio +async def test_subagent_returns_only_its_final_answer(runtime: BubAgent, provider_transport: ProviderService) -> None: + provider_transport.reply_chat("let me look it up", calls=[call("tests_lookup", "l1")]) + provider_transport.reply_chat("the answer") + + result = await run_subagent.run(prompt="find it", context=parent_context(runtime)) + + assert result["status"] == "completed" + assert result["output"] == "the answer" + assert run_subagent.render(result) == "the answer" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("session", ["fork", "inherit"]) +async def test_forked_subagent_sees_the_current_turn_and_is_discarded( + runtime: BubAgent, provider_transport: ProviderService, session: str +) -> None: + provider_transport.reply_chat(calls=[call("subagent", "c1", {"prompt": "summarize it", "session": session})]) + provider_transport.reply_chat("child summary") + provider_transport.reply_chat("parent answer") + + await run_parent(runtime, "parent question") + + child_request = request_text(provider_transport, 1) + assert "parent question" in child_request + assert "summarize it" in child_request + messages = await runtime.tape(session_tape_name("parent", runtime.framework.workspace)).read_messages() + user_texts = [json.dumps(message.to_dict()) for message in messages if message.role == "user"] + assert any("parent question" in text for text in user_texts) + assert not any("summarize it" in text for text in user_texts) + + +@pytest.mark.asyncio +async def test_child_state_gets_its_own_thread_and_drops_parent_scope() -> None: agent = FakeAgent() - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"}) + state = {"_runtime_agent": agent, **PARENT_KEY, ALLOWED_TOOLS_STATE_KEY: ["tests.lookup"], "extra": "val"} - result = await run_subagent.run(prompt="do something", session="inherit", context=ctx) + result = await run_subagent.run(prompt="task", context=FakeContext(state)) - assert result == {"session_id": "user/abc", "output": "agent result", "errors": []} - agent.run_stream.assert_called_once() - call_kwargs = agent.run_stream.call_args.kwargs - assert call_kwargs["session_id"] == "user/abc" - assert call_kwargs["prompt"] == "do something" - assert call_kwargs["model"] is None + child = agent.calls[0] + assert child["session_id"] == f"temp/{result['task_id']}" + assert child["state"]["session_id"] == child["session_id"] + assert child["state"]["_runtime_thread_id"] == f"subagent:{result['task_id']}" + assert ALLOWED_TOOLS_STATE_KEY not in child["state"] + assert child["state"]["extra"] == "val" @pytest.mark.asyncio -async def test_subagent_temp_session() -> None: +async def test_child_tools_stay_within_the_parent_allowlist() -> None: agent = FakeAgent() - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"}) + state = {"_runtime_agent": agent, **PARENT_KEY, ALLOWED_TOOLS_STATE_KEY: ["tests.lookup", "subagent"]} - await run_subagent.run(prompt="task", session="temp", context=ctx) + await run_subagent.run(prompt="task", context=FakeContext(state)) + assert agent.calls[0]["allowed_tools"] == {"tests.lookup"} - call_kwargs = agent.run_stream.call_args.kwargs - assert call_kwargs["session_id"].startswith("temp/") - assert call_kwargs["session_id"] != "user/abc" + with pytest.raises(ValueError, match="tests_other"): + await run_subagent.run(prompt="task", allowed_tools=["tests_other"], context=FakeContext(state)) @pytest.mark.asyncio -async def test_subagent_custom_session() -> None: +async def test_child_cannot_start_or_manage_subagents() -> None: agent = FakeAgent() - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"}) - await run_subagent.run(prompt="task", session="custom/session-1", context=ctx) + await run_subagent.run(prompt="task", allowed_tools=[], context=FakeContext({"_runtime_agent": agent})) - call_kwargs = agent.run_stream.call_args.kwargs - assert call_kwargs["session_id"] == "custom/session-1" + allowed = agent.calls[0]["allowed_tools"] + assert {"tests.lookup", "tests.other"} <= allowed + assert not any(name == "subagent" or name.startswith("subagent.") for name in allowed) @pytest.mark.asyncio -async def test_subagent_passes_model() -> None: +async def test_child_skills_stay_within_the_parent_allowlist() -> None: agent = FakeAgent() - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"}) + state = {"_runtime_agent": agent, "allowed_skills": ["review"]} - await run_subagent.run(prompt="task", model="openai:gpt-4o", context=ctx) + await run_subagent.run(prompt="task", context=FakeContext(state)) + await run_subagent.run(prompt="task", allowed_skills=["Review", "deploy"], context=FakeContext(state)) - call_kwargs = agent.run_stream.call_args.kwargs - assert call_kwargs["model"] == "openai:gpt-4o" + assert [child["allowed_skills"] for child in agent.calls] == [["review"], ["review"]] @pytest.mark.asyncio -async def test_subagent_state_includes_session_id() -> None: +async def test_unknown_allowed_tools_are_rejected_before_running() -> None: agent = FakeAgent() - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc", "extra": "val"}) - await run_subagent.run(prompt="task", session="temp", context=ctx) + with pytest.raises(ValueError, match="tests_missing_tool"): + await run_subagent.run( + prompt="task", allowed_tools=[" tests_missing_tool "], context=FakeContext({"_runtime_agent": agent}) + ) + + assert agent.calls == [] + + +@pytest.mark.asyncio +async def test_background_subagent_notifies_the_parent_through_steering( + runtime: BubAgent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat("background done") + + result = await run_subagent.run(prompt="task", background=True, context=parent_context(runtime)) + assert result["status"] in ("queued", "running") + assert "started in the background" in run_subagent.render(result) + await subagent_manager.get(result["task_id"])._runner + + inbox = runtime.framework.get_steering_inbox() + assert inbox is not None + [message] = await inbox.drain_messages(dict(PARENT_KEY)) + assert f'task_id="{result["task_id"]}" status="completed"' in message.content + assert "background done" in message.content + + +@pytest.mark.asyncio +async def test_waiting_on_a_background_subagent_delivers_its_result_without_a_notification( + runtime: BubAgent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat("background done") + context = parent_context(runtime) + + started = await run_subagent.run(prompt="task", background=True, context=context) + [result] = await subagent_wait.run(task_ids=[started["task_id"]], timeout_seconds=5, context=context) + await subagent_manager.get(started["task_id"])._runner - call_kwargs = agent.run_stream.call_args.kwargs - state = call_kwargs["state"] - # Turn state should contain the subagent session_id, not the original - assert state["session_id"] == call_kwargs["session_id"] - assert state["extra"] == "val" + assert result["status"] == "completed" + assert result["output"] == "background done" + inbox = runtime.framework.get_steering_inbox() + assert inbox is not None + assert await inbox.drain_messages(dict(PARENT_KEY)) == [] @pytest.mark.asyncio -async def test_subagent_default_session_when_missing() -> None: - """When session_id is not in context state, default to 'temp/unknown'.""" +async def test_wait_without_ids_covers_unread_tasks_of_the_session() -> None: agent = FakeAgent() - ctx = FakeContext({"_runtime_agent": agent}) + context = FakeContext({"_runtime_agent": agent, "session_id": "wait-session"}) + first = await run_subagent.run(prompt="one", background=True, context=context) + second = await run_subagent.run(prompt="two", background=True, context=context) - await run_subagent.run(prompt="task", session="inherit", context=ctx) + results = await subagent_wait.run(context=context) + assert [result["task_id"] for result in results] == [first["task_id"], second["task_id"]] + assert await subagent_wait.run(context=context) == [] - call_kwargs = agent.run_stream.call_args.kwargs - assert call_kwargs["session_id"] == "temp/unknown" + +@pytest.mark.asyncio +async def test_wait_returns_running_tasks_after_its_timeout() -> None: + agent = FakeAgent(block=True) + context = FakeContext({"_runtime_agent": agent}) + started = await run_subagent.run(prompt="task", background=True, context=context) + + [result] = await subagent_wait.run(task_ids=[started["task_id"]], timeout_seconds=0, context=context) + + assert result["status"] in ("queued", "running") + agent.release.set() + await subagent_manager.get(started["task_id"])._runner @pytest.mark.asyncio -async def test_subagent_empty_allowed_tools_defaults_to_all_non_subagent_tools() -> None: - tool_name = "tests.allowed_tool_default" +async def test_cancel_stops_a_background_subagent() -> None: + agent = FakeAgent(block=True) + context = FakeContext({"_runtime_agent": agent}) + started = await run_subagent.run(prompt="task", background=True, context=context) + + [result] = await subagent_cancel.run(task_id=started["task_id"], context=context) + + assert result["status"] == "cancelled" + assert "was cancelled" in subagent_cancel.render([result]) - @tool(name=tool_name) - def allowed_tool_default() -> str: - return "ok" - agent = FakeAgent(allowed_tool_default) - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"}) +@pytest.mark.asyncio +async def test_cancel_session_stops_only_that_sessions_tasks() -> None: + agent = FakeAgent(block=True) + mine = await run_subagent.run( + prompt="task", background=True, context=FakeContext({"_runtime_agent": agent, "session_id": "mine"}) + ) + theirs = await run_subagent.run( + prompt="task", background=True, context=FakeContext({"_runtime_agent": agent, "session_id": "theirs"}) + ) - await run_subagent.run(prompt="task", allowed_tools=[], context=ctx) + assert await subagent_manager.cancel_session("mine") == 1 - allowed_tools = agent.run_stream.call_args.kwargs["allowed_tools"] - assert tool_name in allowed_tools - assert "subagent" not in allowed_tools + assert subagent_manager.get(mine["task_id"]).status == "cancelled" + assert subagent_manager.get(theirs["task_id"]).status in ("queued", "running") + agent.release.set() + await subagent_manager.get(theirs["task_id"])._runner @pytest.mark.asyncio -async def test_subagent_resolves_model_tool_aliases_to_runtime_names() -> None: - tool_name = "tests.resolve_subagent" +async def test_cancelling_the_parent_call_cancels_its_foreground_subagent() -> None: + agent = FakeAgent(block=True) + call_task = asyncio.create_task(run_subagent.run(prompt="task", context=FakeContext({"_runtime_agent": agent}))) + while not agent.calls: + await asyncio.sleep(0) + [task] = [task for task in subagent_manager.tasks() if task._request and task._request.agent is agent] + + call_task.cancel() + with pytest.raises(asyncio.CancelledError): + await call_task - @tool(name=tool_name) - def resolve_subagent() -> str: - return "ok" + assert task.status == "cancelled" - agent = FakeAgent(resolve_subagent) - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"}) - await run_subagent.run(prompt="task", allowed_tools=[" tests_resolve_subagent "], context=ctx) +@pytest.mark.asyncio +async def test_subagent_times_out() -> None: + agent = FakeAgent(block=True) - assert agent.run_stream.call_args.kwargs["allowed_tools"] == {tool_name} + with pytest.raises(RuntimeError, match="timed out after 1 seconds"): + await run_subagent.run(prompt="task", timeout_seconds=1, context=FakeContext({"_runtime_agent": agent})) @pytest.mark.asyncio -async def test_subagent_rejects_unknown_allowed_tools() -> None: - agent = FakeAgent() - ctx = FakeContext({"_runtime_agent": agent, "session_id": "user/abc"}) +async def test_concurrency_limit_queues_extra_subagents(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BUB_SUBAGENT_MAX_CONCURRENCY", "1") + agent = FakeAgent(block=True) + context = FakeContext({"_runtime_agent": agent}) + first = await run_subagent.run(prompt="one", background=True, context=context) + second = await run_subagent.run(prompt="two", background=True, context=context) + for _ in range(5): + await asyncio.sleep(0) + + assert subagent_manager.get(first["task_id"]).status == "running" + assert subagent_manager.get(second["task_id"]).status == "queued" + agent.release.set() + results = await subagent_wait.run(task_ids=[first["task_id"], second["task_id"]], context=context) + assert [result["status"] for result in results] == ["completed", "completed"] + + +@pytest.mark.asyncio +async def test_send_steers_a_running_subagent(runtime: BubAgent, provider_transport: ProviderService) -> None: + entered = asyncio.Event() + release = asyncio.Event() + + @tool(name="tests.block") + async def block() -> str: + entered.set() + await release.wait() + return "unblocked" + + runtime.tools[block.name] = block + provider_transport.reply_chat(calls=[call("tests_block", "b1")]) + provider_transport.reply_chat("ok") + context = parent_context(runtime) + + started = await run_subagent.run(prompt="task", background=True, context=context) + await entered.wait() + reply = await subagent_send.run(task_id=started["task_id"], message="extra details", context=context) + release.set() + await subagent_manager.get(started["task_id"])._runner + + assert "Message queued" in reply + assert "extra details" in request_text(provider_transport, 1) + + +@pytest.mark.asyncio +async def test_send_continues_a_finished_named_session(runtime: BubAgent, provider_transport: ProviderService) -> None: + provider_transport.reply_chat("first answer") + provider_transport.reply_chat("second answer") + context = parent_context(runtime) + + first = await run_subagent.run(prompt="first question", session="notes/1", context=context) + second = await subagent_send.run(task_id=first["task_id"], message="second question", context=context) + + assert second["output"] == "second answer" + assert second["task_id"] != first["task_id"] + assert second["session_id"] == "notes/1" + continued = request_text(provider_transport, 1) + assert "first question" in continued + assert "second question" in continued - with pytest.raises(ValueError, match="tests_missing_tool"): - await run_subagent.run(prompt="task", allowed_tools=[" tests_missing_tool "], context=ctx) - agent.run_stream.assert_not_called() +@pytest.mark.asyncio +async def test_send_rejects_a_finished_temporary_session() -> None: + context = FakeContext({"_runtime_agent": FakeAgent()}) + finished = await run_subagent.run(prompt="task", context=context) + + with pytest.raises(ValueError, match="temporary session is gone"): + await subagent_send.run(task_id=finished["task_id"], message="more", context=context) + + +@pytest.mark.asyncio +async def test_subagent_publishes_its_lifecycle_events(runtime: BubAgent, provider_transport: ProviderService) -> None: + provider_transport.reply_chat("child answer") + seen: list[SubagentEvent] = [] + runtime.events.on(SubagentEvent, seen.append) + + result = await run_subagent.run(prompt="task", context=parent_context(runtime)) + + assert isinstance(seen[0], SubagentStarted) + assert isinstance(seen[-1], SubagentFinished) + assert {type(event) for event in seen[1:-1]} == {SubagentOutput} + assert [event.event.kind for event in seen[1:-1] if isinstance(event, SubagentOutput)][-1] == "final" + assert seen[-1].task.task_id == result["task_id"] + assert seen[-1].task.status == "completed" + assert seen[-1].state["session_id"] == "parent" + + +@pytest.mark.asyncio +async def test_failing_event_handler_does_not_fail_the_subagent( + runtime: BubAgent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat("child answer") + + def broken(event: SubagentEvent) -> None: + raise RuntimeError("observer bug") + + runtime.events.on(SubagentEvent, broken) + + result = await run_subagent.run(prompt="task", context=parent_context(runtime)) + + assert result["output"] == "child answer" + + +@pytest.mark.asyncio +async def test_profile_sets_the_child_model_tools_and_instructions( + runtime: BubAgent, provider_transport: ProviderService +) -> None: + profiles = runtime.framework.workspace / ".agents" / "agents" + profiles.mkdir(parents=True) + (profiles / "reviewer.md").write_text( + "---\ndescription: Reviews code changes\nmodel: openai:review-model\ntools: [tests.lookup]\n---\n" + "Only report findings.\n", + encoding="utf-8", + ) + provider_transport.reply_chat(calls=[call("subagent", "c1", {"prompt": "check it", "agent": "Reviewer"})]) + provider_transport.reply_chat("no findings") + provider_transport.reply_chat("parent answer") + + await run_parent(runtime, "review please") + + assert "" in system_prompt(provider_transport, 0) + assert "- reviewer: Reviews code changes" in system_prompt(provider_transport, 0) + child = provider_transport.body(1) + assert child["model"] == "review-model" + assert "Only report findings." in system_prompt(provider_transport, 1) + assert "" not in system_prompt(provider_transport, 1) + assert [item["function"]["name"] for item in child["tools"]] == ["tests_lookup"] + + +@pytest.mark.asyncio +async def test_unknown_profile_is_rejected(runtime: BubAgent) -> None: + with pytest.raises(ValueError, match="unknown subagent profile 'ghost'"): + await run_subagent.run(prompt="task", agent="ghost", context=parent_context(runtime)) + + +@pytest.mark.asyncio +async def test_subagent_task_records_the_tool_call_that_started_it( + runtime: BubAgent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat(calls=[call("subagent", "call-42", {"prompt": "child task"})]) + provider_transport.reply_chat("child answer") + provider_transport.reply_chat("parent answer") + started: list[SubagentStarted] = [] + runtime.events.on(SubagentStarted, started.append) + + await run_parent(runtime, "delegate") + + assert [event.task.parent_call_id for event in started] == ["call-42"] From cddf0a60b2811caa237f7719b12a6faaca38b94f Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Nov 2026 10:28:24 +0800 Subject: [PATCH 3/4] docs: document subagent tasks, profiles and events Co-Authored-By: Claude Opus 5.5 --- .../src/content/docs/docs/app/subagents.mdx | 89 +++++++++++++---- website/src/content/docs/docs/app/tools.mdx | 3 +- .../docs/docs/reference/python-api.mdx | 2 +- .../content/docs/docs/reference/settings.mdx | 6 ++ .../src/content/docs/docs/reference/tools.mdx | 96 ++++++++++++++----- .../content/docs/zh-cn/docs/app/subagents.mdx | 89 +++++++++++++---- .../src/content/docs/zh-cn/docs/app/tools.mdx | 3 +- .../docs/zh-cn/docs/reference/python-api.mdx | 4 + .../docs/zh-cn/docs/reference/settings.mdx | 2 + .../docs/zh-cn/docs/reference/tools.mdx | 85 +++++++++++++--- 10 files changed, 302 insertions(+), 77 deletions(-) diff --git a/website/src/content/docs/docs/app/subagents.mdx b/website/src/content/docs/docs/app/subagents.mdx index d00ee08e..c949df8f 100644 --- a/website/src/content/docs/docs/app/subagents.mdx +++ b/website/src/content/docs/docs/app/subagents.mdx @@ -1,9 +1,9 @@ --- title: Subagents -description: Delegate tasks with the built-in subagent tool, choose a session strategy, and narrow the model, tools, and skills a subagent can use. +description: Delegate tasks with the built-in subagent tool, run them in the background, define reusable subagent profiles, and narrow what a subagent can use. --- -The Bub app includes a built-in `subagent` tool that lets the current agent delegate a specific subtask to another agent instance. +The Bub app includes a built-in `subagent` tool that lets the current agent delegate a specific subtask to a child run of the same agent. Subagents are a good fit for tasks that generate a huge amount of intermediate context — **large-scale retrieval, long document reading, independent code review** — so that only the final conclusions return to the main session and intermediate details don't consume its context window. @@ -18,41 +18,96 @@ Use the subagent tool to review the authentication flow: 3. Return the review findings with the relevant file paths. ``` -The parent agent then calls the `subagent` tool; the child calls tools inside its own loop, finishes the analysis, and returns a summary to the parent. +The parent agent then calls the `subagent` tool; the child calls tools inside its own loop, finishes the analysis, and returns its final answer to the parent. Only that answer, the text of the child's last step, comes back; the child's intermediate narration and tool calls stay out of the parent's context. + +The child sees only its `prompt` (and, with `session="fork"`, the conversation so far), so the prompt should be self-contained: say what to do, what to return, and any limits. Beyond asking by hand, you can also set a rule in the workspace `AGENTS.md` or in a skill (for example, "complex retrieval or code review should be delegated to a read-only subagent first, and the main agent only applies changes based on the review") to get **automatic delegation**. ## Choose a session strategy {#sessions} -The `session` parameter decides how the child's session context is stored and who owns it: +The `session` parameter decides what the child starts from and what is kept: | Session mode | Behavior | Use it for | | --- | --- | --- | -| `temp` (default) | Creates a separate `temp/` temporary session; intermediate records are not merged into the main session afterwards | **Most common.** One-off analysis or research that returns conclusions only and keeps the main context clean | -| `inherit` | Reuses the parent agent's current `session_id` | When every interaction of the subtask must be recorded in full in the main session history | -| A custom string | Uses that fixed session ID | Longer tasks whose child session you need to resume or inspect later | +| `temp` (default) | A new, empty `temp/` session, discarded afterwards | **Most common.** One-off analysis or research that returns conclusions only | +| `fork` | Starts from a copy of the current conversation, including the current turn so far; discarded afterwards | Subtasks that need what the user and the parent already said, without the parent re-explaining it | +| A custom string | Uses that fixed session ID, which is kept | Longer work you want to continue later with `subagent.send`, or inspect afterwards | + +`inherit` is accepted as an alias of `fork`. A fork copies the conversation at the moment of the call, so the child does not see what the parent does next, and nothing the child does is written back to the parent's history. + +## Run in the background {#background} + +By default the parent waits for the subagent. With `background=true`, the tool returns a task ID at once and the parent keeps working, so several subagents can run while the parent does something else: + +```text +Start three background subagents, one per service, to check the logs for errors. +Keep working on the migration; summarize their findings when they are done. +``` + +When a background task ends, its result reaches the parent session as a steering message: + +```text + +No errors in the payments service logs since the last deploy. + +``` + +If the parent's turn is still running, it reads the notification before its next step. If the turn already ended, the notification arrives with the session's next turn. The parent can also manage tasks directly: + +| Tool | What it does | +| --- | --- | +| `subagent.wait` | Return results, waiting up to `timeout_seconds` (default 60; `0` only checks). Without `task_ids`, covers the session's unread tasks. A result read here sends no notification. | +| `subagent.send` | Message a running task, which reads it before its next step; or continue a finished task that used a named session. | +| `subagent.cancel` | Stop a running task. | + +At most `BUB_SUBAGENT_MAX_CONCURRENCY` subagents (default 4) run at once; later ones wait in a queue. `timeout_seconds` on the call, or `BUB_SUBAGENT_TIMEOUT_SECONDS` globally, stops a child that runs too long. Ending Bub, or the `,quit` command for a session, cancels its running subagents. + +## Define subagent profiles {#profiles} + +A profile is a reusable subagent: a markdown file in `.agents/agents/` in the workspace, or in `~/.agents/agents/` for every workspace. The front matter sets its configuration, and the body is added to the child's system prompt: + +```markdown title=".agents/agents/reviewer.md" +--- +description: Reviews a change for bugs and reports findings without editing files +model: openai:gpt-5-mini +tools: [fs.read, bash, tape.search] +skills: [] +timeout_seconds: 600 +--- + +You review code. Read the relevant files, report concrete findings with file +paths and line numbers, and never modify files. +``` + +| Field | Meaning | +| --- | --- | +| `description` | Required. Tells the parent when to use the profile | +| `name` | Optional; defaults to the file name without `.md` | +| `model`, `reasoning_effort` | The child's model and reasoning effort | +| `tools`, `skills` | The child's allowed tools and skills (a list, or a comma-separated string) | +| `timeout_seconds` | The child's time limit | -:::tip -The default `temp` mode returns only the final `output` text and any `errors` to the parent agent; every prompt and tool call produced inside the subtask stays out of the main conversation's context. -::: +When the `subagent` tool is available, the parent's system prompt lists the profiles, and the model uses one by passing its name as `agent`. Arguments given in the call take precedence over the profile. A workspace profile overrides a home profile with the same name. ## State sharing and capability boundaries {#boundaries} -A subagent is derived from the same running instance as its parent. What it inherits and what stays isolated: +A subagent is a child run of the same agent instance as its parent. What it inherits and what stays isolated: | Resource | Boundary | Notes and controls | | --- | --- | --- | | **Base instance** | Fully shared | Shares plugin hooks, model credentials, and `BubAgent` configuration | | **Execution environment** | Fully shared | Shares the workspace directory, local filesystem, Shell processes, and network access | -| **Model selection** | Set separately | Defaults to the parent model; use `model` (e.g. `model="gpt-4o-mini"`) to downgrade to a lighter model | -| **Tools and skills** | Narrowed by parameter | All available by default; use `allowed_tools` and `allowed_skills` to narrow the set | -| **Conversation records** | Isolated per session strategy | Decided by `session`; `temp` returns only the final summary text to the parent | +| **Model selection** | Set separately | Defaults to the profile's model or the parent's; use `model` to pick a lighter one | +| **Tools and skills** | At most the parent's | Defaults to the parent run's tools and skills; `allowed_tools` and `allowed_skills` narrow them further. Children cannot start subagents | +| **Turn state** | Copied | The child gets a copy of the parent's turn state with its own steering key, so messages sent to the parent stay with the parent | +| **Conversation records** | Per session strategy | `temp` and `fork` are discarded; a named session is kept | -Parameter types and defaults are listed in the [subagent reference](/docs/reference/tools/#subagent). +Parameter types and defaults are listed in the [subagent reference](/docs/reference/tools/#subagent). Channels can observe subagents through [events](/docs/reference/tools/#subagent-events). ### Security limits {#security-limits} Like the parent, a subagent also runs in the local environment under the current user's privileges, and can affect the current system: -1. A temporary session isolates only **conversation records and model context**, not files, network requests, or system processes. File changes and network requests made by the child have real system side effects and are not rolled back on exit just because `temp` was used. -2. Parameters such as `allowed_tools` can restrict model behavior when needed, but **they are not an enforced security boundary**. To hard-block dangerous commands or out-of-scope tools, use [tool interception hooks](/docs/reference/hooks/#interception-through-pluginagenthooks); for physical isolation, configure a separate [execution environment](/docs/app/tools/#environment). +1. A session strategy isolates only **conversation records and model context**, not files, network requests, or system processes. File changes and network requests made by the child have real system side effects and are not rolled back on exit. +2. In chat, the model writes the `subagent` arguments, so `allowed_tools` restricts model behavior but **is not an enforced security boundary**. To hard-block dangerous commands or out-of-scope tools, use [tool interception hooks](/docs/reference/hooks/#interception-through-pluginagenthooks); for physical isolation, configure a separate [execution environment](/docs/app/tools/#environment). diff --git a/website/src/content/docs/docs/app/tools.mdx b/website/src/content/docs/docs/app/tools.mdx index 0031e87d..43b0ef6a 100644 --- a/website/src/content/docs/docs/app/tools.mdx +++ b/website/src/content/docs/docs/app/tools.mdx @@ -20,7 +20,8 @@ The Bub app ships with the following core tools: | **Session management** | `tape.info`, `tape.search` | Check token usage and search conversation records | | | `tape.handoff`, `tape.reset` | Move the context start point to compact the session, or archive and reset the context | | | `spill.read` | Read large tool results that exceed the context limit in chunks | -| **Multi-task coordination** | [`subagent`](/docs/app/subagents/) | Start a subagent to handle tasks in parallel in a separate session | +| **Multi-task coordination** | [`subagent`](/docs/app/subagents/) | Delegate a task to a subagent, in the foreground or in the background | +| | `subagent.wait`, `subagent.send`, `subagent.cancel` | Read background results, message a subagent, or stop it | | **Code mode** | `run_code` | Compose multiple tools in Python when code mode is on | Built-in tool parameters and defaults are listed in the [tools reference](/docs/reference/tools/). diff --git a/website/src/content/docs/docs/reference/python-api.mdx b/website/src/content/docs/docs/reference/python-api.mdx index c49e2652..c360a32a 100644 --- a/website/src/content/docs/docs/reference/python-api.mdx +++ b/website/src/content/docs/docs/reference/python-api.mdx @@ -88,7 +88,7 @@ from bub.core import Tool, ToolContext, tool `Tool` fields: `name`, `handler`, `description`, `parameters`, `context`, `exposure` (`auto` / `direct` / `code` / `command`), `renderer`, `output_schema`, and `deferred`. -`@tool` / `tool(...)` constructs a `Tool` from a callable; `@tool(model=...)` uses a Pydantic parameter model. The decorator does not register globally; provide tools through `Agent(tools=[...])` or `provide_tools`. `ToolContext` contains `tape`, `run_id`, `state`, `code_mode`, `command`, and `events`. +`@tool` / `tool(...)` constructs a `Tool` from a callable; `@tool(model=...)` uses a Pydantic parameter model. The decorator does not register globally; provide tools through `Agent(tools=[...])` or `provide_tools`. `ToolContext` contains `tape`, `run_id`, `state`, `code_mode`, `command`, `events`, `step_messages` (the user messages the calling model step sent, which the Tape records only after its tool calls finish), and `call_id` (the model's ID of the call; calls from code mode share the `run_code` call's ID). ```python from bub.core.tools import content_result, result_content diff --git a/website/src/content/docs/docs/reference/settings.mdx b/website/src/content/docs/docs/reference/settings.mdx index a4a96e48..df6e85f3 100644 --- a/website/src/content/docs/docs/reference/settings.mdx +++ b/website/src/content/docs/docs/reference/settings.mdx @@ -56,6 +56,8 @@ Every YAML key has a corresponding environment variable, which takes precedence. | `BUB_PROVIDERS` | `{}` | `providers` | [Custom providers](#custom-providers) | | `BUB_MAX_STEPS` | `sys.maxsize` | `max_steps` | [AgentSettings](#agent-settings) | | `BUB_MAX_TOKENS` | `16384` | `max_tokens` | [AgentSettings](#agent-settings) | +| `BUB_SUBAGENT_MAX_CONCURRENCY` | `4` | `subagent_max_concurrency` | [AgentSettings](#agent-settings) | +| `BUB_SUBAGENT_TIMEOUT_SECONDS` | `null` | `subagent_timeout_seconds` | [AgentSettings](#agent-settings) | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | [AgentSettings](#agent-settings) | | `BUB_CLIENT_ARGS` | `{}` | `client_args` | [`client_args`](#client-args) | | `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | [`completion_args`](#completion-args) | @@ -94,6 +96,8 @@ class AgentSettings(Settings): providers: dict[str, CustomProvider] = {} max_steps: int = sys.maxsize max_tokens: int = 16384 + subagent_max_concurrency: int = 4 + subagent_timeout_seconds: int | None = None model_timeout_seconds: int | None = None client_args: dict[str, Any] = {} completion_args: dict[str, Any] = {} @@ -112,6 +116,8 @@ class AgentSettings(Settings): | `BUB_PROVIDERS` | `{}` | `providers` | JSON object defining custom providers | | `BUB_MAX_STEPS` | `sys.maxsize` | `max_steps` | Maximum model Steps per Run; must be a positive integer | | `BUB_MAX_TOKENS` | `16384` | `max_tokens` | Default output limit; omitted for Codex calls | +| `BUB_SUBAGENT_MAX_CONCURRENCY` | `4` | `subagent_max_concurrency` | Maximum [subagents](/docs/app/subagents/) running at once in the process; later ones wait in a queue | +| `BUB_SUBAGENT_TIMEOUT_SECONDS` | `null` | `subagent_timeout_seconds` | Default time limit of one subagent run; `null` means no limit | | `BUB_MODEL_TIMEOUT_SECONDS` | `null` | `model_timeout_seconds` | Seconds for one model call, including streaming | | `BUB_CLIENT_ARGS` | `{}` | `client_args` | JSON object passed to the model client | | `BUB_COMPLETION_ARGS` | `{}` | `completion_args` | JSON object passed to each generation | diff --git a/website/src/content/docs/docs/reference/tools.mdx b/website/src/content/docs/docs/reference/tools.mdx index 60b6204f..4ecc0cde 100644 --- a/website/src/content/docs/docs/reference/tools.mdx +++ b/website/src/content/docs/docs/reference/tools.mdx @@ -155,53 +155,97 @@ Clear the current Tape. With `archive=true`, first archive the main Tape and sid ### `subagent` {#subagent-tool} ```text -subagent(prompt, model=None, session="temp", allowed_tools=None, allowed_skills=None) +subagent(prompt, agent=None, description=None, model=None, reasoning_effort=None, + session="temp", allowed_tools=None, allowed_skills=None, background=False, + timeout_seconds=None) ``` | Parameter | Type and default | Description | | --- | --- | --- | -| `prompt` | String or content-block list, required | Initial input for the subagent | -| `model` | `str \| None`, default `None` | `provider:model` for this child run; by default, uses the current configuration | +| `prompt` | String or content-block list, required | The task for the subagent | +| `agent` | `str \| None`, default `None` | Name of a [subagent profile](/docs/app/subagents/#profiles); its settings apply unless given here | +| `description` | `str \| None`, default `None` | Short label shown in task lists; defaults to the prompt's first line | +| `model` | `str \| None`, default `None` | `provider:model` for the child run; by default, the profile's model or the current configuration | +| `reasoning_effort` | `str \| None`, default `None` | Reasoning effort of the child's model | | `session` | `str`, default `"temp"` | Session strategy for the subagent | -| `allowed_tools` | `list[str] \| None`, default `None` | Tools available to the subagent; by default, allows the parent Agent's tools except `subagent` | -| `allowed_skills` | `list[str] \| None`, default `None` | Skills discoverable by the subagent; unrestricted by default | +| `allowed_tools` | `list[str] \| None`, default `None` | Tools the subagent may use; by default, the parent run's tools | +| `allowed_skills` | `list[str] \| None`, default `None` | Skills the subagent may use; by default, the parent run's skills | +| `background` | `bool`, default `False` | Return a task ID at once and keep running in the background | +| `timeout_seconds` | `int \| None`, default `None` | Stop the child after this many seconds; defaults to the profile's limit, then `BUB_SUBAGENT_TIMEOUT_SECONDS` | -Tool names can be runtime names or model aliases, so both `fs.read` and `fs_read` work; unknown names produce an error. Skill names are case-insensitive, and `allowed_skills=[]` disables skill discovery. To minimize tool access, explicitly provide a non-empty `allowed_tools` list. +Tool names can be runtime names or model aliases, so both `fs.read` and `fs_read` work. A child never gets more than its parent: the tools default to the parent run's allowed tools, and naming a tool outside them is an error. Skill names are case-insensitive and are likewise narrowed to the parent's; `allowed_skills=[]` disables skill discovery. Children cannot start or manage subagents, so `subagent` and `subagent.*` are always excluded. -`session` determines which session the subagent uses: +`session` determines what the subagent starts from and what is kept: | Value | Behavior | | --- | --- | -| `temp` | Creates a `temp/` session; new Tape entries are not merged back into the parent session | -| `inherit` | Uses the current `session_id`, recording the child task in the same session history | -| Any other string | Uses the specified session ID, allowing the child session to be resumed or inspected later | +| `temp` | Creates an empty `temp/` session that is discarded afterwards | +| `fork` | Starts from a copy of the current conversation, including the current turn so far, and is discarded afterwards; `inherit` is accepted as an alias | +| Any other string | Uses that session ID; the session is kept and can be continued later with `subagent.send` | -The default temporary session returns only final `output` and `errors` to the parent Agent and does not retain the subagent's intermediate conversation. With `inherit`, parent and child runs may write to the same session; avoid running them concurrently when a clear history matters, or use a separate named session. The session ID and workspace together generate the Tape name, with the same merge rules as calling `BubAgent.run_stream()` directly. +A fork copies the parent's Tape when the call is made, so the child does not see what the parent records later. Each child gets its own turn state: a shallow copy of the parent's without the parent's steering key, tool scope or running Tape, so messages steered into the parent stay with the parent. -A read-only review can be restricted as follows: +The tool returns: -```json +```python { - "prompt": "Review the authentication flow. Do not modify files.", - "session": "temp", - "allowed_tools": ["fs.read", "bash"], - "allowed_skills": [] + "task_id": "sub-1a2b3c4d", + "session_id": "temp/sub-1a2b3c4d", + "status": "completed", # queued, running, completed, failed or cancelled + "output": "...", + "errors": [], } ``` -This JSON shows the `subagent` tool arguments, not a command format to send manually. `allowed_tools` is the actual tool filter for the child run, but in chat, **the model still generates the arguments**. If permissions are a security boundary, also enforce constraints in `BubAgent.run_stream()` or `before_tool_call`; do not rely only on natural-language instructions. +`output` is the child's final answer: the text of its last step that made no tool calls. A foreground subagent that fails without an answer raises a tool error. A background subagent returns at once with status `queued` or `running`; when it ends, the parent session receives its result as a steering message: -The tool returns `session_id`, collected text `output`, and `errors`: +```text + +... + +``` -```python -{ - "session_id": "temp/1a2b3c4d", - "output": "...", - "errors": [], -} +A running parent turn reads it before its next step; otherwise it arrives with the session's next turn. Results read with `subagent.wait` send no notification. + +At most `BUB_SUBAGENT_MAX_CONCURRENCY` subagents run at once in the process; later ones wait with status `queued`. Ending the runtime, or the `quit` command for a session, cancels running subagents. When a `temp` or `fork` subagent ends, the background shells it started are stopped. + +### `subagent.wait` {#subagent-wait} + +```text +subagent.wait(task_ids=None, timeout_seconds=60) +``` + +Return task results, waiting up to `timeout_seconds` for the tasks to finish; `0` checks without waiting. Without `task_ids`, it covers the session's tasks whose results were not read yet. Tasks still running after the timeout are returned with their current status. + +### `subagent.send` {#subagent-send} + +```text +subagent.send(task_id, message, background=False) ``` -`output` collects only text deltas; other stream events remain observable through events and tracing. The subagent shares its `BubAgent`, plugin hooks, model credentials, workspace, `Environment`, files, and external systems with the parent Agent. Turn state is a shallow copy, so nested mutable objects may still be shared. A temporary session isolates **conversation records and model context**, not file, network, or process side effects; using `temp` does not roll back those effects. To isolate execution, provide a separate [execution environment](/docs/app/tools/#environment); for approval, use [tool interception](/docs/sdk/events/#control-tool-call). +Send a message to a task. A running task receives it before its next step. A finished task with a named session continues that conversation as a new task, bounded by the current run's tools; a finished `temp` or `fork` task cannot be continued. + +### `subagent.cancel` {#subagent-cancel} + +```text +subagent.cancel(task_id) +``` + +Stop a running task. Its status becomes `cancelled`, and any partial output is kept. + +### Events {#subagent-events} + +Every task publishes events on the agent's event bus (`agent.events`), with the starting run's turn state as `state`: + +| Event | When | +| --- | --- | +| `SubagentStarted` | The task left the queue and its child run is starting | +| `SubagentOutput` | The child run produced a stream event, available as `event` | +| `SubagentFinished` | The task ended as `completed`, `failed` or `cancelled` | + +Each carries `task`, a `SubagentTask` with `task_id`, `session_id`, `parent_session_id`, `parent_call_id`, `description`, `model`, `profile`, `status`, `output`, `errors`, `usage`, step and tool-call counts, and timestamps. They are importable from `bub.builtin.subagents`. A failing handler is logged and does not fail the child run. + +The subagent shares its `BubAgent`, plugin hooks, model credentials, workspace, `Environment`, files, and external systems with the parent Agent. A session strategy isolates **conversation records and model context**, not file, network, or process side effects. To isolate execution, provide a separate [execution environment](/docs/app/tools/#environment); for approval, use [tool interception](/docs/sdk/events/#control-tool-call). In chat, **the model generates the arguments**, so `allowed_tools` is not a security boundary on its own. See [Subagents](/docs/app/subagents/) for complete usage examples. diff --git a/website/src/content/docs/zh-cn/docs/app/subagents.mdx b/website/src/content/docs/zh-cn/docs/app/subagents.mdx index f14ed07b..b30135ce 100644 --- a/website/src/content/docs/zh-cn/docs/app/subagents.mdx +++ b/website/src/content/docs/zh-cn/docs/app/subagents.mdx @@ -1,9 +1,9 @@ --- title: 子 Agent -description: 使用内置 subagent 工具委派任务,选择会话策略,并收窄子 Agent 的模型、工具和技能。 +description: 使用内置 subagent 工具委派任务、在后台运行子任务、定义可复用的子 Agent 配置,并收窄子 Agent 的可用能力。 --- -Bub 应用内置 `subagent` 工具,允许当前 Agent 将特定子任务委派给另一个单独的 Agent 实例。 +Bub 应用内置 `subagent` 工具,允许当前 Agent 将特定子任务委派给同一个 Agent 的一次子运行。 子 Agent 适合将**大量检索、长文档阅读、独立代码审查**等产生海量中间上下文的任务从主对话中剥离出去,只将最终结论带回主会话,从而防止主会话的上下文窗口被额外信息占用。 @@ -18,42 +18,95 @@ Bub 应用内置 `subagent` 工具,允许当前 Agent 将特定子任务委派 3. 返回审查发现并附带相关文件路径。 ``` -父 Agent 随后调用 `subagent` 工具,子 Agent 在其独立循环中调用工具完成分析,最后把结果汇总返回。 +父 Agent 随后调用 `subagent` 工具,子 Agent 在其独立循环中调用工具完成分析,最后把最终答案返回给父 Agent。返回的只有这个答案,也就是子 Agent 最后一步的文本;中间的叙述和工具调用不会进入父 Agent 的上下文。 + +子 Agent 只能看到 `prompt`(使用 `session="fork"` 时还能看到当前对话),因此 prompt 应当自成一体:写清楚要做什么、返回什么、有哪些限制。 除了人工要求外,也可以在工作区 `AGENTS.md` 或技能中设定规则(例如:「复杂检索或代码审查应先委派给只读子 Agent,主 Agent 仅根据审查结论执行修改」)来实现**自动委派**。 ## 选择会话模式 {#sessions} -`subagent` 工具通过 `session` 参数决定会话上下文的存储与归属: +`session` 参数决定子 Agent 从什么开始、结束后保留什么: | 会话模式 | 行为 | 适用场景 | | --- | --- | --- | -| `temp`(默认) | 创建独立的 `temp/` 临时会话,执行完毕后中间记录不并入主会话 | **最常用**。一次性分析或调研,仅返回结论,保持主上下文干净 | -| `inherit` | 直接沿用父 Agent 的当前 `session_id` | 需要把子任务的每一步交互都完整记录到主会话历史中 | -| 自定义字符串 | 使用指定的固定会话 ID | 任务较长,后续需要恢复或人工调阅该子会话记录 | +| `temp`(默认) | 新建空的 `temp/` 会话,结束后丢弃 | **最常用**。一次性分析或调研,仅返回结论 | +| `fork` | 从当前对话(包括本轮已有内容)的副本开始,结束后丢弃 | 子任务需要用户和父 Agent 已经说过的内容,又不想让父 Agent 重新复述 | +| 自定义字符串 | 使用指定的固定会话 ID,会话会被保留 | 较长的工作,之后要用 `subagent.send` 继续,或事后查看 | + +`inherit` 作为 `fork` 的别名仍可使用。fork 在调用发生时复制对话,子 Agent 看不到父 Agent 之后的动作,子 Agent 的记录也不会写回父会话历史。 + +## 后台运行 {#background} + +默认情况下父 Agent 会等待子 Agent 完成。设置 `background=true` 后,工具立即返回任务 ID,父 Agent 继续工作,多个子 Agent 可以在父 Agent 做别的事时同时运行: + +```text +启动三个后台子 Agent,每个服务一个,检查日志中的错误。 +你继续做迁移;它们完成后汇总结论。 +``` + +后台任务结束时,结果会以 steering 消息的形式送达父会话: + +```text + +自上次部署以来,支付服务日志中没有错误。 + +``` + +如果父 Agent 的这一轮还在进行,它会在下一步之前读到通知;如果这一轮已经结束,通知会随会话的下一轮送达。父 Agent 也可以直接管理任务: + +| 工具 | 作用 | +| --- | --- | +| `subagent.wait` | 返回结果,最多等待 `timeout_seconds`(默认 60;`0` 只查看不等待)。不传 `task_ids` 时覆盖本会话中尚未读取的任务。在这里读到的结果不会再发通知。 | +| `subagent.send` | 给运行中的任务发消息,它会在下一步之前读到;或者继续一个使用命名会话、已经结束的任务。 | +| `subagent.cancel` | 停止运行中的任务。 | + +同时运行的子 Agent 最多 `BUB_SUBAGENT_MAX_CONCURRENCY` 个(默认 4),超出的排队等待。调用时的 `timeout_seconds`,或全局的 `BUB_SUBAGENT_TIMEOUT_SECONDS`,会停止运行过久的子 Agent。退出 Bub,或对某个会话执行 `,quit`,都会取消其运行中的子 Agent。 + +## 定义子 Agent 配置 {#profiles} + +子 Agent 配置(profile)是一个可复用的子 Agent:放在工作区 `.agents/agents/` 下的 markdown 文件,或放在 `~/.agents/agents/` 下供所有工作区使用。front matter 设定配置,正文会加入子 Agent 的系统提示词: + +```markdown title=".agents/agents/reviewer.md" +--- +description: 审查改动中的 bug 并报告发现,不修改文件 +model: openai:gpt-5-mini +tools: [fs.read, bash, tape.search] +skills: [] +timeout_seconds: 600 +--- + +你负责代码审查。阅读相关文件,报告具体问题并附上文件路径和行号,绝不修改文件。 +``` -:::tip -默认的 `temp` 模式只会向父 Agent 返回最终的 `output` 文本和发生的 `errors`,子任务中间产生的所有提示词、工具调用均不会占用主对话的上下文。 -::: +| 字段 | 含义 | +| --- | --- | +| `description` | 必填。告诉父 Agent 何时使用该配置 | +| `name` | 可选;默认为去掉 `.md` 的文件名 | +| `model`、`reasoning_effort` | 子 Agent 的模型和推理强度 | +| `tools`、`skills` | 子 Agent 可用的工具和技能(列表,或逗号分隔的字符串) | +| `timeout_seconds` | 子 Agent 的运行时限 | +当 `subagent` 工具可用时,父 Agent 的系统提示词会列出这些配置,模型通过把配置名传给 `agent` 来使用。调用时显式给出的参数优先于配置。工作区中的配置会覆盖 home 目录下的同名配置。 ## 状态共享与能力边界 {#boundaries} -子 Agent 与父 Agent 基于同一个运行实例派生,其继承与隔离边界如下: +子 Agent 是父 Agent 所在实例的一次子运行,其继承与隔离边界如下: | 资源类别 | 边界关系 | 说明与控制参数 | | --- | --- | --- | | **基础实例** | 完全共享 | 共享插件 Hooks、模型凭据与 `BubAgent` 配置 | | **执行环境** | 完全共享 | 共享工作区目录、本地文件系统、Shell 进程和网络访问 | -| **模型选择** | 可单独指定 | 默认沿用父模型,可用 `model`(如 `model="gpt-4o-mini"`)降级使用轻量模型 | -| **工具与技能** | 可参数收窄 | 默认沿用全部,可用 `allowed_tools`、`allowed_skills` 缩小可用范围 | -| **会话记录** | 视策略隔离 | 由 `session` 参数决定,`temp` 仅向父 Agent 返回最终汇总文本 | +| **模型选择** | 可单独指定 | 默认使用配置中的模型或父模型,可用 `model` 换成轻量模型 | +| **工具与技能** | 不超过父 Agent | 默认沿用父 Agent 本轮的工具和技能,`allowed_tools`、`allowed_skills` 可以进一步收窄。子 Agent 不能再启动子 Agent | +| **轮次状态** | 复制 | 子 Agent 得到父 Agent 轮次状态的副本,并使用自己的 steering 键,发给父 Agent 的消息仍归父 Agent | +| **会话记录** | 视策略而定 | `temp` 和 `fork` 结束后丢弃;命名会话会被保留 | -各参数的详细类型定义及默认值见 [subagent 参考](/zh-cn/docs/reference/tools/#subagent)。 +各参数的详细类型定义及默认值见 [subagent 参考](/zh-cn/docs/reference/tools/#subagent)。Channel 可以通过[事件](/zh-cn/docs/reference/tools/#subagent-events)观察子 Agent。 ### 安全限制 {#security-limits} -和父 Agent 一样,子 Agent 本质上也运行在当前用户权限下的本地环境中,有可能会当前系统产生影响: +和父 Agent 一样,子 Agent 本质上也运行在当前用户权限下的本地环境中,可能会对当前系统产生影响: -1. 用临时会话隔离的仅是**对话记录与模型上下文**,而不是文件、网络或系统进程。子 Agent 对文件进行的修改、发送的网络请求均具有真实的系统副作用,不会因为使用 `temp` 模式而在退出时回滚。 -2. 必要时可通过 `allowed_tools` 等参数限制模型行为,**不构成强制安全边界**。如需对危险命令或越权工具进行硬性拦截,必须使用[工具拦截 Hooks](/zh-cn/docs/app/plugins/#control-a-model-or-tool-call);如需物理环境隔离,应配置独立的[执行环境](/zh-cn/docs/app/tools/#environment)。 +1. 会话策略隔离的仅是**对话记录与模型上下文**,而不是文件、网络或系统进程。子 Agent 对文件进行的修改、发送的网络请求均具有真实的系统副作用,不会在退出时回滚。 +2. 在对话中,`subagent` 的参数由模型生成,因此 `allowed_tools` 只能限制模型行为,**不构成强制安全边界**。如需对危险命令或越权工具进行硬性拦截,必须使用[工具拦截 Hooks](/zh-cn/docs/app/plugins/#control-a-model-or-tool-call);如需物理环境隔离,应配置独立的[执行环境](/zh-cn/docs/app/tools/#environment)。 diff --git a/website/src/content/docs/zh-cn/docs/app/tools.mdx b/website/src/content/docs/zh-cn/docs/app/tools.mdx index db146f07..ebf5db4a 100644 --- a/website/src/content/docs/zh-cn/docs/app/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/app/tools.mdx @@ -20,7 +20,8 @@ description: 了解 Bub 应用内置工具、工具调用方式以及如何配 | **会话管理** | `tape.info`、`tape.search` | 查看 Token 用量、检索历史会话记录 | | | `tape.handoff`、`tape.reset` | 移动上下文起点以压缩会话,或归档并重置上下文 | | | `spill.read` | 分块读取超出上下文上限的大型工具输出 | -| **多任务协作**| [`subagent`](/zh-cn/docs/app/subagents/) | 启动子 Agent 在独立会话中并行处理任务 | +| **多任务协作**| [`subagent`](/zh-cn/docs/app/subagents/) | 把任务委派给子 Agent,前台等待或后台运行 | +| | `subagent.wait`、`subagent.send`、`subagent.cancel` | 读取后台结果、给子 Agent 发消息,或停止它 | | **代码模式** | `run_code` | 在 代码模式 开启时通过 Python 组合多个工具 | 内置工具的详细参数及默认值见 [工具参考](/zh-cn/docs/reference/tools/)。 diff --git a/website/src/content/docs/zh-cn/docs/reference/python-api.mdx b/website/src/content/docs/zh-cn/docs/reference/python-api.mdx index 87c5f699..607f0893 100644 --- a/website/src/content/docs/zh-cn/docs/reference/python-api.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/python-api.mdx @@ -154,8 +154,12 @@ class ToolContext: code_mode: bool = False command: bool = False events: EventBus | None = field(default=None, repr=False, compare=False) + step_messages: list[republic.Message] = field(default_factory=list, repr=False, compare=False) + call_id: str | None = None ``` +`step_messages` 是发起调用的模型步骤发送的用户消息(Tape 要等工具调用结束后才记录它们);`call_id` 是模型给这次调用的 ID,代码模式中的调用沿用 `run_code` 调用的 ID。 + ### `Tool` {#tools} ```python diff --git a/website/src/content/docs/zh-cn/docs/reference/settings.mdx b/website/src/content/docs/zh-cn/docs/reference/settings.mdx index 104f614e..6b05056a 100644 --- a/website/src/content/docs/zh-cn/docs/reference/settings.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/settings.mdx @@ -50,6 +50,8 @@ description: 应用层环境变量、YAML 字段、默认值和解析顺序。 | `BUB_PROVIDERS` | `providers` | `dict` | `{}` | JSON 对象,定义自定义提供商 | | `BUB_MAX_STEPS` | `max_steps` | `int` | `sys.maxsize` | 单次 Run 的模型 Step 上限;须为正整数 | | `BUB_MAX_TOKENS` | `max_tokens` | `int` | `16384` | 默认输出上限;Codex 调用不传 | +| `BUB_SUBAGENT_MAX_CONCURRENCY` | `subagent_max_concurrency` | `int` | `4` | 进程内同时运行的[子 Agent](/zh-cn/docs/app/subagents/) 上限,超出的排队等待 | +| `BUB_SUBAGENT_TIMEOUT_SECONDS` | `subagent_timeout_seconds` | `int \| null` | `null` | 单个子 Agent 运行的默认时限;`null` 表示不限 | | `BUB_MODEL_TIMEOUT_SECONDS` | `model_timeout_seconds` | `int \| null` | `null` | 一次模型调用(含流式)的秒数 | | `BUB_CLIENT_ARGS` | `client_args` | `dict` | `{}` | JSON 对象,传给模型客户端 | | `BUB_COMPLETION_ARGS` | `completion_args` | `dict` | `{}` | JSON 对象,传给每次生成 | diff --git a/website/src/content/docs/zh-cn/docs/reference/tools.mdx b/website/src/content/docs/zh-cn/docs/reference/tools.mdx index 019f8870..f206d815 100644 --- a/website/src/content/docs/zh-cn/docs/reference/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/tools.mdx @@ -178,36 +178,95 @@ tape.reset(archive=False) ### `subagent` {#subagent-tool} ```text -subagent(prompt, model=None, session="temp", allowed_tools=None, allowed_skills=None) +subagent(prompt, agent=None, description=None, model=None, reasoning_effort=None, + session="temp", allowed_tools=None, allowed_skills=None, background=False, + timeout_seconds=None) ``` | 参数 | 类型 | 说明 | | --- | --- | --- | -| `prompt` | `str \| list` | 子 Agent 的初始输入 | -| `model` | `str \| None` | 本次子运行的 `provider:model`;默认沿用当前配置 | -| `session` | `str` | 子 Agent 的会话策略 | -| `allowed_tools` | `list[str] \| None` | 可用工具;默认继承父 Agent 的工具,但排除 `subagent` | -| `allowed_skills` | `list[str] \| None` | 可发现的技能;`[]` 禁用技能发现 | - -工具名支持运行时名称和模型别名;技能名不区分大小写。 +| `prompt` | `str \| list` | 交给子 Agent 的任务 | +| `agent` | `str \| None` | [子 Agent 配置](/zh-cn/docs/app/subagents/#profiles)名;调用中未给出的参数取自配置 | +| `description` | `str \| None` | 任务列表中显示的简短标签;默认取 prompt 第一行 | +| `model` | `str \| None` | 子运行的 `provider:model`;默认取配置中的模型或当前配置 | +| `reasoning_effort` | `str \| None` | 子 Agent 模型的推理强度 | +| `session` | `str` | 子 Agent 的会话策略,默认 `"temp"` | +| `allowed_tools` | `list[str] \| None` | 可用工具;默认为父 Agent 本轮的工具 | +| `allowed_skills` | `list[str] \| None` | 可用技能;默认为父 Agent 本轮的技能,`[]` 禁用技能发现 | +| `background` | `bool` | 立即返回任务 ID,在后台继续运行;默认 `False` | +| `timeout_seconds` | `int \| None` | 超时即停止子 Agent;默认取配置中的时限,再取 `BUB_SUBAGENT_TIMEOUT_SECONDS` | + +工具名支持运行时名称和模型别名;技能名不区分大小写。子 Agent 的工具和技能不会超过父 Agent:默认就是父 Agent 本轮允许的范围,指定范围外的工具会报错。子 Agent 不能启动或管理子 Agent,`subagent` 和 `subagent.*` 始终被排除。 | `session` | 行为 | | --- | --- | -| `temp` | 创建 `temp/` 会话,不合并新产生的 Tape 条目 | -| `inherit` | 使用当前 `session_id`,写入同一会话历史 | -| 其他字符串 | 使用指定的会话 ID | +| `temp` | 新建空的 `temp/` 会话,结束后丢弃 | +| `fork` | 从当前对话(包括本轮已有内容)的副本开始,结束后丢弃;`inherit` 为其别名 | +| 其他字符串 | 使用指定的会话 ID,会话会被保留,之后可用 `subagent.send` 继续 | + +fork 在调用时复制父 Agent 的 Tape,子 Agent 看不到父 Agent 之后记录的内容。每个子 Agent 拥有自己的轮次状态:父状态的浅拷贝,但去掉了父 Agent 的 steering 键、工具范围和运行中的 Tape,因此发给父 Agent 的 steering 消息仍归父 Agent。 返回结构: ```python { - "session_id": "temp/1a2b3c4d", + "task_id": "sub-1a2b3c4d", + "session_id": "temp/sub-1a2b3c4d", + "status": "completed", # queued、running、completed、failed 或 cancelled "output": "...", "errors": [], } ``` -`output` 只汇总文本增量。`temp` 只隔离对话记录和模型上下文,不隔离文件、网络或进程副作用。 +`output` 是子 Agent 的最终答案,即它最后一个没有工具调用的步骤的文本。前台子 Agent 失败且没有答案时抛出工具错误。后台子 Agent 立即以 `queued` 或 `running` 状态返回;结束时,父会话会收到一条 steering 消息: + +```text + +... + +``` + +父 Agent 的轮次仍在进行时,会在下一步之前读到它;否则随会话的下一轮送达。用 `subagent.wait` 读过的结果不再发送通知。 + +进程内同时运行的子 Agent 最多 `BUB_SUBAGENT_MAX_CONCURRENCY` 个,其余以 `queued` 状态排队。运行时退出,或对某个会话执行 `quit` 命令,会取消运行中的子 Agent。`temp` 或 `fork` 子 Agent 结束时,它启动的后台 Shell 会被停止。 + +### `subagent.wait` {#subagent-wait} + +```text +subagent.wait(task_ids=None, timeout_seconds=60) +``` + +返回任务结果,最多等待 `timeout_seconds` 让任务结束;`0` 只查看不等待。不传 `task_ids` 时覆盖本会话尚未读取的任务。超时后仍在运行的任务以当前状态返回。 + +### `subagent.send` {#subagent-send} + +```text +subagent.send(task_id, message, background=False) +``` + +给任务发消息。运行中的任务在下一步之前读到它。使用命名会话且已结束的任务会作为新任务继续该对话,工具范围受当前运行约束;已结束的 `temp` 或 `fork` 任务无法继续。 + +### `subagent.cancel` {#subagent-cancel} + +```text +subagent.cancel(task_id) +``` + +停止运行中的任务。状态变为 `cancelled`,已有的部分输出会保留。 + +### 事件 {#subagent-events} + +每个任务都会在 Agent 的事件总线(`agent.events`)上发布事件,`state` 为发起它的那次运行的轮次状态: + +| 事件 | 时机 | +| --- | --- | +| `SubagentStarted` | 任务离开队列,子运行即将开始 | +| `SubagentOutput` | 子运行产生一个流事件,见 `event` | +| `SubagentFinished` | 任务以 `completed`、`failed` 或 `cancelled` 结束 | + +每个事件都带有 `task`(`SubagentTask`),包含 `task_id`、`session_id`、`parent_session_id`、`parent_call_id`、`description`、`model`、`profile`、`status`、`output`、`errors`、`usage`、步数和工具调用数以及时间戳。它们可从 `bub.builtin.subagents` 导入。处理器抛出的异常只会被记录,不会让子运行失败。 + +子 Agent 与父 Agent 共享 `BubAgent`、插件 Hooks、模型凭据、工作区、`Environment`、文件和外部系统。会话策略只隔离对话记录和模型上下文,不隔离文件、网络或进程副作用。在对话中参数由模型生成,`allowed_tools` 本身不构成安全边界。 ## 代码模式 与 spill {#code-and-spill} From 2e0692b38c4278120776130c1702086af15a4049 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Nov 2026 10:40:05 +0800 Subject: [PATCH 4/4] feat(builtin)!: always run subagents in the background Drop the `background` parameter of `subagent` and `subagent.send`: every subagent returns a task ID at once and reports with a . So that a parent never ends its turn before its subagents report, a run that started subagent tasks waits for the next one to finish when a step would end the run, then continues with its notification. `MessagesPending.step_ended` tells that emission apart from the one made while a request is built. A notification sent through the steering inbox now marks the task delivered, so a result is never handed to the parent twice. Co-Authored-By: Claude Opus 5.5 --- src/bub/builtin/agent.py | 7 +- src/bub/builtin/subagents.py | 117 +++++----- src/bub/core/agent.py | 2 +- src/bub/core/events.py | 5 +- tests/test_sdk_isolation.py | 4 +- tests/test_subagent_tool.py | 207 +++++++++++------- tests/test_tracing.py | 4 +- .../src/content/docs/docs/app/subagents.mdx | 14 +- website/src/content/docs/docs/app/tools.mdx | 4 +- .../docs/docs/reference/python-api.mdx | 2 +- .../src/content/docs/docs/reference/tools.mdx | 12 +- .../content/docs/zh-cn/docs/app/subagents.mdx | 14 +- .../src/content/docs/zh-cn/docs/app/tools.mdx | 4 +- .../docs/zh-cn/docs/reference/python-api.mdx | 2 +- .../docs/zh-cn/docs/reference/tools.mdx | 12 +- 15 files changed, 242 insertions(+), 168 deletions(-) diff --git a/src/bub/builtin/agent.py b/src/bub/builtin/agent.py index 9adfad23..4368f438 100644 --- a/src/bub/builtin/agent.py +++ b/src/bub/builtin/agent.py @@ -27,7 +27,8 @@ class BubAgent(core.Agent): """Bub's agent: the core loop configured by settings and the framework's hooks. Adds commands, skills, code mode and deferred tools to :class:`bub.core.Agent`, and - connects the framework's agent-loop hooks through :class:`AgentPlugins`. System prompt + connects the framework's agent-loop hooks through :class:`AgentPlugins`. A run that + started subagents does not end before their results arrive. System prompt sections, in order: a subagent's profile instructions, code mode, deferred tools, skills, subagent profiles, the framework's ``system_prompt`` hooks, then sections added with ``system_prompt.add``. The framework's hooks handle @@ -87,6 +88,10 @@ def __init__( command_prefix=self.settings.command_prefix if command_prefix is None else command_prefix, ) self.plugins.attach(self, tools=tools is None) + from bub.builtin.subagents import deliver_subagent_results + + # After the plugins' steering handler, so steered messages are seen first. + self.events.on(core.MessagesPending, deliver_subagent_results) async def run_stream( self, diff --git a/src/bub/builtin/subagents.py b/src/bub/builtin/subagents.py index 138adcd1..33ec01c3 100644 --- a/src/bub/builtin/subagents.py +++ b/src/bub/builtin/subagents.py @@ -1,10 +1,11 @@ -"""Subagents: child runs of a Bub agent, in the foreground or as background tasks. +"""Subagents: child runs of a Bub agent, run as background tasks. -The ``subagent`` tool starts a child run of the same agent and tracks it as a -:class:`SubagentTask`. A foreground task returns its answer as the tool result; a -background task returns a task ID at once, keeps running, and reports its result to the -parent session as a ```` steering message, unless the parent read -it first with ``subagent.wait``. +The ``subagent`` tool starts a child run of the same agent in the background, tracks it +as a :class:`SubagentTask` and returns its task ID at once. The result reaches the parent +as a ```` message, unless the parent read it first with +``subagent.wait``: a run that started tasks waits for them when a step would end the run +(see :func:`deliver_subagent_results`), and a task that ends after its run reaches the +session through the steering inbox. Each task publishes :class:`SubagentStarted`, :class:`SubagentOutput` (one per child stream event) and :class:`SubagentFinished` on the agent's event bus, so channels can @@ -33,7 +34,7 @@ from loguru import logger from pydantic import BaseModel, Field -from bub.core.events import AgentEvent, EventBus +from bub.core.events import AgentEvent, EventBus, MessagesPending from bub.core.tools import ToolContext, tool from bub.streaming import StreamEvent from bub.turn import TurnState @@ -93,14 +94,6 @@ class SubAgentInput(BaseModel): None, description="Skill names the sub-agent may use; defaults to the skills available to the current agent.", ) - background: bool = Field( - False, - description=( - "Run in the background and return a task ID at once. The result arrives later as a " - " message; use subagent.wait, subagent.send and subagent.cancel " - "to manage the task." - ), - ) timeout_seconds: int | None = Field(None, gt=0, description="Stop the sub-agent after this many seconds.") @@ -157,7 +150,6 @@ class SubagentTask: description: str model: str | None profile: str | None - background: bool persistent: bool parent_call_id: str | None = None """The ID of the tool call that started the task, when the model made it.""" @@ -172,7 +164,7 @@ class SubagentTask: started_at: float | None = None ended_at: float | None = None delivered: bool = False - """Whether the parent has read the result; a delivered result sends no notification.""" + """Whether the result was handed to the parent; a delivered result sends no further notification.""" _parent_key: TurnState = field(default_factory=dict, repr=False) _request: _RunRequest | None = field(default=None, repr=False) _events: EventBus | None = field(default=None, repr=False) @@ -290,7 +282,6 @@ async def start( parent_state: TurnState, description: str | None, profile: str | None, - background: bool, parent_call_id: str | None = None, ) -> SubagentTask: """Queue a child run and return its task; the run starts once a slot is free.""" @@ -304,7 +295,6 @@ async def start( description=description or _describe(prompt), model=request.model, profile=profile, - background=background, persistent=persistent, parent_call_id=parent_call_id, _parent_key=_steering_key(parent_state), @@ -317,22 +307,33 @@ async def start( task._runner = asyncio.create_task(self._run(task, prompt), name=f"subagent {task_id}") return task - async def wait(self, tasks: Iterable[SubagentTask], timeout: float | None) -> None: - """Wait until every task ends or ``timeout`` seconds pass; their results count as read.""" + async def wait(self, tasks: Iterable[SubagentTask], timeout: float | None, *, any_task: bool = False) -> None: + """Wait until every task (or, with ``any_task``, one) ends or ``timeout`` seconds pass. + + Ended tasks count as delivered: their results go to the waiter, not to a notification. + """ tasks = list(tasks) + if not tasks: + return for task in tasks: task._waiters += 1 + waiters = [asyncio.ensure_future(task.wait()) for task in tasks] try: - async with asyncio.timeout(timeout): - await asyncio.gather(*(task.wait() for task in tasks)) - except TimeoutError: - pass + await asyncio.wait( + waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED if any_task else asyncio.ALL_COMPLETED + ) finally: + for waiter in waiters: + waiter.cancel() for task in tasks: task._waiters -= 1 if task.done: task.delivered = True + def unread(self, state: TurnState) -> list[SubagentTask]: + """Tasks started by the run whose turn state is ``state`` and not delivered yet.""" + return [task for task in self._tasks.values() if task._parent_state is state and not task.delivered] + async def cancel(self, task_id: str) -> SubagentTask: task = self.get(task_id) await self._cancel_tasks([task]) @@ -447,7 +448,9 @@ async def _settle(self, task: SubagentTask) -> None: await shell_manager.terminate_session(task.session_id) task._done.set() await _publish(events, SubagentFinished(state=parent_state, task=task)) - if task.background and not task.delivered and task._waiters == 0 and framework is not None: + if not task.delivered and task._waiters == 0 and framework is not None: + # The parent session reads it with its next step, or with its next turn. + task.delivered = True await framework.steer_message( message=_channel_message(task.parent_session_id, task.task_id, render_notification(task)), session_id=task.parent_session_id, @@ -515,9 +518,10 @@ def _render_result(result: SubAgentResult | str) -> str: errors = "".join(f"[Error: {message}]" for message in result["errors"]) if result["status"] in ("queued", "running"): return ( - f"Subagent task {result['task_id']} started in the background (session {result['session_id']}). " - "Its result arrives as a message; use subagent.wait to check or block " - "on it, subagent.send to message it, or subagent.cancel to stop it." + f"Subagent task {result['task_id']} is running in the background (session {result['session_id']}). " + "Its result arrives as a message before this turn ends, so keep working " + "or finish your reply; use subagent.wait to get it sooner, subagent.send to message the task, " + "or subagent.cancel to stop it." ) if result["status"] == "cancelled": partial = f"\nPartial output:\n{result['output']}" if result["output"] else "" @@ -699,14 +703,13 @@ async def _build_request(param: SubAgentInput, context: ToolContext) -> tuple[_R ) -async def _start_and_maybe_wait( +async def _start( request: _RunRequest, prompt: str | list[dict], *, context: ToolContext, description: str | None, profile: str | None, - background: bool, ) -> SubAgentResult: task = await subagent_manager.start( request, @@ -715,38 +718,47 @@ async def _start_and_maybe_wait( parent_state=context.state, description=description, profile=profile, - background=background, parent_call_id=getattr(context, "call_id", None), ) - if background: - return task.result() - task.delivered = True - try: - await task.wait() - except asyncio.CancelledError: - # The parent's tool call was cancelled: its foreground child goes with it. - await subagent_manager.cancel(task.task_id) - raise - if task.status == "failed" and not task.output: - raise RuntimeError(f"subagent {task.task_id} failed: {'; '.join(task.errors) or 'unknown error'}") return task.result() +async def deliver_subagent_results(event: MessagesPending) -> None: + """Keep a run going until the subagents it started have reported. + + When a step ends without tool calls, the run would end. If it started subagent tasks + whose results it has not received, wait for one of them to finish (unless other + messages already continue the run), then add each finished task's notification so the + run takes another step with it. + """ + if not event.step_ended: + return + tasks = subagent_manager.unread(event.state) + if not tasks: + return + if not event.messages: + await subagent_manager.wait([task for task in tasks if not task.done], None, any_task=True) + for task in tasks: + if task.done: + task.delivered = True + event.messages.append([render_notification(task)]) + + @tool(name=SUBAGENT_TOOL_NAME, context=True, model=SubAgentInput, renderer=_render_result) async def run_subagent(param: SubAgentInput, *, context: ToolContext) -> SubAgentResult: - """Delegate a task to a sub-agent that runs its own loop with this agent's tools and returns its answer. + """Delegate a task to a sub-agent that runs its own loop with this agent's tools, in the background. - The sub-agent only sees `prompt` (and, with session='fork', the current conversation), so make - the prompt self-contained. Set background=true to keep working while it runs. + Returns a task ID at once; the sub-agent's answer arrives later as a + message. Start several to work in parallel. The sub-agent only sees `prompt` (and, with + session='fork', the current conversation), so make the prompt self-contained. """ request, profile = await _build_request(param, context) - return await _start_and_maybe_wait( + return await _start( request, param.prompt, context=context, description=param.description, profile=profile.name if profile else None, - background=param.background, ) @@ -779,13 +791,11 @@ async def subagent_cancel(task_id: str, *, context: ToolContext) -> list[SubAgen @tool(name="subagent.send", context=True, renderer=_render_result) -async def subagent_send( - task_id: str, message: str, background: bool = False, *, context: ToolContext -) -> SubAgentResult | str: +async def subagent_send(task_id: str, message: str, *, context: ToolContext) -> SubAgentResult | str: """Send a message to a subagent task. A running task receives it before its next step. A finished task with a named session - continues that conversation as a new task, in the background when background=true. + continues that conversation as a new background task. """ task = subagent_manager.get(task_id) if not task.done: @@ -811,13 +821,12 @@ async def subagent_send( timeout_seconds=request.timeout_seconds, base_state=dict(context.state), ) - return await _start_and_maybe_wait( + return await _start( continued, message, context=context, description=task.description, profile=task.profile, - background=background, ) diff --git a/src/bub/core/agent.py b/src/bub/core/agent.py index b96e82b7..21cc1103 100644 --- a/src/bub/core/agent.py +++ b/src/bub/core/agent.py @@ -555,7 +555,7 @@ async def _run_steps( # Messages left on the last allowed step stay with their source. if not should_continue and step < self.max_steps: - pending = (await self._emit(MessagesPending, tape, prompt=None)).messages + pending = (await self._emit(MessagesPending, tape, prompt=None, step_ended=True)).messages if not (should_continue or pending): await self._record_step(tape, step, start, "ok", prompt=next_prompt, stream=state) tracker.status = "ok" diff --git a/src/bub/core/events.py b/src/bub/core/events.py index 79dc561e..375e0e0e 100644 --- a/src/bub/core/events.py +++ b/src/bub/core/events.py @@ -207,11 +207,14 @@ class MessagesPending(TapeEvent): calls; messages supplied then continue the run for another step. Append each message's content to ``messages``. They are sent in order before the next step's prompt and recorded with it. ``prompt`` is the building step's prompt; it is None - when the step continues the conversation and when a step ends. + when the step continues the conversation and when a step ends. ``step_ended`` tells + the two emissions apart: it is true when a step ended without tool calls, where + handlers may wait for messages that would continue the run. """ prompt: list[UserContent] | None messages: list[list[UserContent]] = field(default_factory=list) + step_ended: bool = False @dataclass(kw_only=True) diff --git a/tests/test_sdk_isolation.py b/tests/test_sdk_isolation.py index 72407ebb..3de70e7b 100644 --- a/tests/test_sdk_isolation.py +++ b/tests/test_sdk_isolation.py @@ -5,6 +5,7 @@ from bub.builtin import BubAgent from bub.builtin.codemode import run_code, set_code_mode from bub.builtin.model import BubModel +from bub.builtin.subagents import subagent_wait from bub.builtin.tools import resolve_tool_names, run_subagent, show_help from bub.core import Agent as CoreAgent from bub.core.store import InMemoryTapeStore @@ -193,7 +194,8 @@ async def test_subagent_uses_parent_instance_tools( tape=tape, state={"_runtime_agent": agent, "session_id": "parent", "_runtime_workspace": str(framework.workspace)}, ) - result = await run_subagent.run(prompt="lookup", allowed_tools=allowed_tools, context=context) + started = await run_subagent.run(prompt="lookup", allowed_tools=allowed_tools, context=context) + [result] = await subagent_wait.run(task_ids=[started["task_id"]], context=context) assert result["output"] == "done" assert run_subagent.render(result) == "done" assert await step_tool_names(steps[-1]) == ["sdk_lookup"] diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index 451c65c7..a2dc9f0b 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -16,6 +16,7 @@ SubagentEvent, SubagentFinished, SubagentOutput, + SubAgentResult, SubagentStarted, run_subagent, subagent_cancel, @@ -64,14 +65,17 @@ def __init__(self, state: dict[str, Any]) -> None: class FakeAgent: """Records child runs; ``block`` holds each run until ``release`` is set.""" - def __init__(self, *tools: Tool, block: bool = False) -> None: + def __init__(self, *tools: Tool, block: bool = False, error: Exception | None = None) -> None: self.tools = {item.name: item for item in (lookup, other, *SUBAGENT_TOOLS, *tools)} self.calls: list[dict[str, Any]] = [] self.block = block + self.error = error self.release = asyncio.Event() async def run_stream(self, **kwargs: Any) -> AsyncStreamEvents: self.calls.append(kwargs) + if self.error is not None: + raise self.error async def iterator() -> AsyncIterator[StreamEvent]: if self.block: @@ -115,12 +119,74 @@ async def run_parent(agent: BubAgent, prompt: str) -> None: pass -def request_text(provider: ProviderService, index: int) -> str: - return json.dumps(provider.body(index)) +async def run_child(context: Any, **arguments: Any) -> SubAgentResult: + """Start a subagent and wait for its result.""" + started = await run_subagent.run(context=context, **arguments) + [result] = await subagent_wait.run(task_ids=[started["task_id"]], timeout_seconds=30, context=context) + return result -def system_prompt(provider: ProviderService, index: int) -> str: - return provider.body(index)["messages"][0]["content"] +def requests_ending_with(provider: ProviderService, text: str) -> list[dict[str, Any]]: + """Request bodies whose last message is a user message containing ``text``. + + A parent run and its subagents call the model concurrently, so tests find a request + by what it asks rather than by its position. + """ + bodies = [provider.body(index) for index in range(len(provider.requests))] + return [ + body + for body in bodies + if body["messages"][-1]["role"] == "user" and text in json.dumps(body["messages"][-1]["content"]) + ] + + +def system_prompt(body: dict[str, Any]) -> str: + return body["messages"][0]["content"] + + +def reply_texts(provider: ProviderService, count: int) -> None: + """Queue interchangeable plain answers for requests whose order is not fixed.""" + for _ in range(count): + provider.reply_chat("noted") + + +@pytest.mark.asyncio +async def test_subagent_returns_a_task_at_once() -> None: + agent = FakeAgent(block=True) + context = FakeContext({"_runtime_agent": agent}) + + started = await run_subagent.run(prompt="task", context=context) + + assert started["status"] in ("queued", "running") + assert started["output"] == "" + assert "running in the background" in run_subagent.render(started) + agent.release.set() + [result] = await subagent_wait.run(task_ids=[started["task_id"]], context=context) + assert result["status"] == "completed" + assert result["output"] == "agent result" + + +@pytest.mark.asyncio +async def test_run_waits_for_its_subagents_before_ending( + runtime: BubAgent, provider_transport: ProviderService +) -> None: + provider_transport.reply_chat(calls=[call("subagent", "c1", {"prompt": "child task"})]) + reply_texts(provider_transport, 3) + started: list[SubagentStarted] = [] + runtime.events.on(SubagentStarted, started.append) + + await run_parent(runtime, "delegate") + + [task] = [event.task for event in started] + assert task.done + assert task.delivered + [last] = requests_ending_with(provider_transport, " None: provider_transport.reply_chat(calls=[call("subagent", "c1", {"prompt": "child task"})]) - provider_transport.reply_chat("child answer") - provider_transport.reply_chat("parent answer") + reply_texts(provider_transport, 3) @runtime.events.on(ToolCallStarted) async def steer_parent(event: ToolCallStarted) -> None: @@ -142,9 +207,9 @@ async def steer_parent(event: ToolCallStarted) -> None: await run_parent(runtime, "delegate") - assert "child task" in request_text(provider_transport, 1) - assert "user follow-up" not in request_text(provider_transport, 1) - assert "user follow-up" in request_text(provider_transport, 2) + [child] = requests_ending_with(provider_transport, "child task") + assert "user follow-up" not in json.dumps(child) + assert requests_ending_with(provider_transport, "user follow-up") @pytest.mark.asyncio @@ -152,7 +217,7 @@ async def test_subagent_returns_only_its_final_answer(runtime: BubAgent, provide provider_transport.reply_chat("let me look it up", calls=[call("tests_lookup", "l1")]) provider_transport.reply_chat("the answer") - result = await run_subagent.run(prompt="find it", context=parent_context(runtime)) + result = await run_child(parent_context(runtime), prompt="find it") assert result["status"] == "completed" assert result["output"] == "the answer" @@ -165,14 +230,12 @@ async def test_forked_subagent_sees_the_current_turn_and_is_discarded( runtime: BubAgent, provider_transport: ProviderService, session: str ) -> None: provider_transport.reply_chat(calls=[call("subagent", "c1", {"prompt": "summarize it", "session": session})]) - provider_transport.reply_chat("child summary") - provider_transport.reply_chat("parent answer") + reply_texts(provider_transport, 3) await run_parent(runtime, "parent question") - child_request = request_text(provider_transport, 1) - assert "parent question" in child_request - assert "summarize it" in child_request + [child] = requests_ending_with(provider_transport, "summarize it") + assert "parent question" in json.dumps(child) messages = await runtime.tape(session_tape_name("parent", runtime.framework.workspace)).read_messages() user_texts = [json.dumps(message.to_dict()) for message in messages if message.role == "user"] assert any("parent question" in text for text in user_texts) @@ -184,7 +247,7 @@ async def test_child_state_gets_its_own_thread_and_drops_parent_scope() -> None: agent = FakeAgent() state = {"_runtime_agent": agent, **PARENT_KEY, ALLOWED_TOOLS_STATE_KEY: ["tests.lookup"], "extra": "val"} - result = await run_subagent.run(prompt="task", context=FakeContext(state)) + result = await run_child(FakeContext(state), prompt="task") child = agent.calls[0] assert child["session_id"] == f"temp/{result['task_id']}" @@ -199,7 +262,7 @@ async def test_child_tools_stay_within_the_parent_allowlist() -> None: agent = FakeAgent() state = {"_runtime_agent": agent, **PARENT_KEY, ALLOWED_TOOLS_STATE_KEY: ["tests.lookup", "subagent"]} - await run_subagent.run(prompt="task", context=FakeContext(state)) + await run_child(FakeContext(state), prompt="task") assert agent.calls[0]["allowed_tools"] == {"tests.lookup"} with pytest.raises(ValueError, match="tests_other"): @@ -210,7 +273,7 @@ async def test_child_tools_stay_within_the_parent_allowlist() -> None: async def test_child_cannot_start_or_manage_subagents() -> None: agent = FakeAgent() - await run_subagent.run(prompt="task", allowed_tools=[], context=FakeContext({"_runtime_agent": agent})) + await run_child(FakeContext({"_runtime_agent": agent}), prompt="task", allowed_tools=[]) allowed = agent.calls[0]["allowed_tools"] assert {"tests.lookup", "tests.other"} <= allowed @@ -222,8 +285,8 @@ async def test_child_skills_stay_within_the_parent_allowlist() -> None: agent = FakeAgent() state = {"_runtime_agent": agent, "allowed_skills": ["review"]} - await run_subagent.run(prompt="task", context=FakeContext(state)) - await run_subagent.run(prompt="task", allowed_skills=["Review", "deploy"], context=FakeContext(state)) + await run_child(FakeContext(state), prompt="task") + await run_child(FakeContext(state), prompt="task", allowed_skills=["Review", "deploy"]) assert [child["allowed_skills"] for child in agent.calls] == [["review"], ["review"]] @@ -241,16 +304,26 @@ async def test_unknown_allowed_tools_are_rejected_before_running() -> None: @pytest.mark.asyncio -async def test_background_subagent_notifies_the_parent_through_steering( +async def test_failing_child_reports_failed_status() -> None: + agent = FakeAgent(error=RuntimeError("child exploded")) + + result = await run_child(FakeContext({"_runtime_agent": agent}), prompt="task") + + assert result["status"] == "failed" + assert result["errors"] == ["child exploded"] + + +@pytest.mark.asyncio +async def test_subagent_ending_after_its_run_notifies_the_session_through_steering( runtime: BubAgent, provider_transport: ProviderService ) -> None: provider_transport.reply_chat("background done") - result = await run_subagent.run(prompt="task", background=True, context=parent_context(runtime)) - assert result["status"] in ("queued", "running") - assert "started in the background" in run_subagent.render(result) - await subagent_manager.get(result["task_id"])._runner + result = await run_subagent.run(prompt="task", context=parent_context(runtime)) + task = subagent_manager.get(result["task_id"]) + await task._runner + assert task.delivered inbox = runtime.framework.get_steering_inbox() assert inbox is not None [message] = await inbox.drain_messages(dict(PARENT_KEY)) @@ -259,13 +332,13 @@ async def test_background_subagent_notifies_the_parent_through_steering( @pytest.mark.asyncio -async def test_waiting_on_a_background_subagent_delivers_its_result_without_a_notification( +async def test_waiting_on_a_subagent_delivers_its_result_without_a_notification( runtime: BubAgent, provider_transport: ProviderService ) -> None: provider_transport.reply_chat("background done") context = parent_context(runtime) - started = await run_subagent.run(prompt="task", background=True, context=context) + started = await run_subagent.run(prompt="task", context=context) [result] = await subagent_wait.run(task_ids=[started["task_id"]], timeout_seconds=5, context=context) await subagent_manager.get(started["task_id"])._runner @@ -280,8 +353,8 @@ async def test_waiting_on_a_background_subagent_delivers_its_result_without_a_no async def test_wait_without_ids_covers_unread_tasks_of_the_session() -> None: agent = FakeAgent() context = FakeContext({"_runtime_agent": agent, "session_id": "wait-session"}) - first = await run_subagent.run(prompt="one", background=True, context=context) - second = await run_subagent.run(prompt="two", background=True, context=context) + first = await run_subagent.run(prompt="one", context=context) + second = await run_subagent.run(prompt="two", context=context) results = await subagent_wait.run(context=context) assert [result["task_id"] for result in results] == [first["task_id"], second["task_id"]] @@ -292,7 +365,7 @@ async def test_wait_without_ids_covers_unread_tasks_of_the_session() -> None: async def test_wait_returns_running_tasks_after_its_timeout() -> None: agent = FakeAgent(block=True) context = FakeContext({"_runtime_agent": agent}) - started = await run_subagent.run(prompt="task", background=True, context=context) + started = await run_subagent.run(prompt="task", context=context) [result] = await subagent_wait.run(task_ids=[started["task_id"]], timeout_seconds=0, context=context) @@ -302,10 +375,10 @@ async def test_wait_returns_running_tasks_after_its_timeout() -> None: @pytest.mark.asyncio -async def test_cancel_stops_a_background_subagent() -> None: +async def test_cancel_stops_a_subagent() -> None: agent = FakeAgent(block=True) context = FakeContext({"_runtime_agent": agent}) - started = await run_subagent.run(prompt="task", background=True, context=context) + started = await run_subagent.run(prompt="task", context=context) [result] = await subagent_cancel.run(task_id=started["task_id"], context=context) @@ -316,11 +389,9 @@ async def test_cancel_stops_a_background_subagent() -> None: @pytest.mark.asyncio async def test_cancel_session_stops_only_that_sessions_tasks() -> None: agent = FakeAgent(block=True) - mine = await run_subagent.run( - prompt="task", background=True, context=FakeContext({"_runtime_agent": agent, "session_id": "mine"}) - ) + mine = await run_subagent.run(prompt="task", context=FakeContext({"_runtime_agent": agent, "session_id": "mine"})) theirs = await run_subagent.run( - prompt="task", background=True, context=FakeContext({"_runtime_agent": agent, "session_id": "theirs"}) + prompt="task", context=FakeContext({"_runtime_agent": agent, "session_id": "theirs"}) ) assert await subagent_manager.cancel_session("mine") == 1 @@ -331,27 +402,14 @@ async def test_cancel_session_stops_only_that_sessions_tasks() -> None: await subagent_manager.get(theirs["task_id"])._runner -@pytest.mark.asyncio -async def test_cancelling_the_parent_call_cancels_its_foreground_subagent() -> None: - agent = FakeAgent(block=True) - call_task = asyncio.create_task(run_subagent.run(prompt="task", context=FakeContext({"_runtime_agent": agent}))) - while not agent.calls: - await asyncio.sleep(0) - [task] = [task for task in subagent_manager.tasks() if task._request and task._request.agent is agent] - - call_task.cancel() - with pytest.raises(asyncio.CancelledError): - await call_task - - assert task.status == "cancelled" - - @pytest.mark.asyncio async def test_subagent_times_out() -> None: agent = FakeAgent(block=True) - with pytest.raises(RuntimeError, match="timed out after 1 seconds"): - await run_subagent.run(prompt="task", timeout_seconds=1, context=FakeContext({"_runtime_agent": agent})) + result = await run_child(FakeContext({"_runtime_agent": agent}), prompt="task", timeout_seconds=1) + + assert result["status"] == "failed" + assert result["errors"] == ["timed out after 1 seconds"] @pytest.mark.asyncio @@ -359,8 +417,8 @@ async def test_concurrency_limit_queues_extra_subagents(monkeypatch: pytest.Monk monkeypatch.setenv("BUB_SUBAGENT_MAX_CONCURRENCY", "1") agent = FakeAgent(block=True) context = FakeContext({"_runtime_agent": agent}) - first = await run_subagent.run(prompt="one", background=True, context=context) - second = await run_subagent.run(prompt="two", background=True, context=context) + first = await run_subagent.run(prompt="one", context=context) + second = await run_subagent.run(prompt="two", context=context) for _ in range(5): await asyncio.sleep(0) @@ -387,14 +445,14 @@ async def block() -> str: provider_transport.reply_chat("ok") context = parent_context(runtime) - started = await run_subagent.run(prompt="task", background=True, context=context) + started = await run_subagent.run(prompt="task", context=context) await entered.wait() reply = await subagent_send.run(task_id=started["task_id"], message="extra details", context=context) release.set() await subagent_manager.get(started["task_id"])._runner assert "Message queued" in reply - assert "extra details" in request_text(provider_transport, 1) + assert requests_ending_with(provider_transport, "extra details") @pytest.mark.asyncio @@ -403,21 +461,21 @@ async def test_send_continues_a_finished_named_session(runtime: BubAgent, provid provider_transport.reply_chat("second answer") context = parent_context(runtime) - first = await run_subagent.run(prompt="first question", session="notes/1", context=context) - second = await subagent_send.run(task_id=first["task_id"], message="second question", context=context) + first = await run_child(context, prompt="first question", session="notes/1") + started = await subagent_send.run(task_id=first["task_id"], message="second question", context=context) + [second] = await subagent_wait.run(task_ids=[started["task_id"]], context=context) assert second["output"] == "second answer" assert second["task_id"] != first["task_id"] assert second["session_id"] == "notes/1" - continued = request_text(provider_transport, 1) - assert "first question" in continued - assert "second question" in continued + [continued] = requests_ending_with(provider_transport, "second question") + assert "first question" in json.dumps(continued) @pytest.mark.asyncio async def test_send_rejects_a_finished_temporary_session() -> None: context = FakeContext({"_runtime_agent": FakeAgent()}) - finished = await run_subagent.run(prompt="task", context=context) + finished = await run_child(context, prompt="task") with pytest.raises(ValueError, match="temporary session is gone"): await subagent_send.run(task_id=finished["task_id"], message="more", context=context) @@ -429,7 +487,7 @@ async def test_subagent_publishes_its_lifecycle_events(runtime: BubAgent, provid seen: list[SubagentEvent] = [] runtime.events.on(SubagentEvent, seen.append) - result = await run_subagent.run(prompt="task", context=parent_context(runtime)) + result = await run_child(parent_context(runtime), prompt="task") assert isinstance(seen[0], SubagentStarted) assert isinstance(seen[-1], SubagentFinished) @@ -451,7 +509,7 @@ def broken(event: SubagentEvent) -> None: runtime.events.on(SubagentEvent, broken) - result = await run_subagent.run(prompt="task", context=parent_context(runtime)) + result = await run_child(parent_context(runtime), prompt="task") assert result["output"] == "child answer" @@ -468,17 +526,17 @@ async def test_profile_sets_the_child_model_tools_and_instructions( encoding="utf-8", ) provider_transport.reply_chat(calls=[call("subagent", "c1", {"prompt": "check it", "agent": "Reviewer"})]) - provider_transport.reply_chat("no findings") - provider_transport.reply_chat("parent answer") + reply_texts(provider_transport, 3) await run_parent(runtime, "review please") - assert "" in system_prompt(provider_transport, 0) - assert "- reviewer: Reviews code changes" in system_prompt(provider_transport, 0) - child = provider_transport.body(1) + parent = provider_transport.body(0) + assert "" in system_prompt(parent) + assert "- reviewer: Reviews code changes" in system_prompt(parent) + [child] = requests_ending_with(provider_transport, "check it") assert child["model"] == "review-model" - assert "Only report findings." in system_prompt(provider_transport, 1) - assert "" not in system_prompt(provider_transport, 1) + assert "Only report findings." in system_prompt(child) + assert "" not in system_prompt(child) assert [item["function"]["name"] for item in child["tools"]] == ["tests_lookup"] @@ -493,8 +551,7 @@ async def test_subagent_task_records_the_tool_call_that_started_it( runtime: BubAgent, provider_transport: ProviderService ) -> None: provider_transport.reply_chat(calls=[call("subagent", "call-42", {"prompt": "child task"})]) - provider_transport.reply_chat("child answer") - provider_transport.reply_chat("parent answer") + reply_texts(provider_transport, 3) started: list[SubagentStarted] = [] runtime.events.on(SubagentStarted, started.append) diff --git a/tests/test_tracing.py b/tests/test_tracing.py index ff820122..f16cd19f 100644 --- a/tests/test_tracing.py +++ b/tests/test_tracing.py @@ -116,7 +116,9 @@ async def test_subagent_is_nested_under_its_tool( spans: Any, agent: BubAgent, provider_transport: ProviderService ) -> None: provider_transport.reply_chat(calls=[call("subagent", "child", {"prompt": "child task"})]) - provider_transport.reply_chat("child done") + # The parent's next steps and the background child call the model in either order. + provider_transport.reply_chat() + provider_transport.reply_chat() provider_transport.reply_chat() events = await agent.run_stream( session_id="parent", prompt="delegate", state={"_runtime_agent": agent}, allowed_tools=["subagent"] diff --git a/website/src/content/docs/docs/app/subagents.mdx b/website/src/content/docs/docs/app/subagents.mdx index c949df8f..571f1f9e 100644 --- a/website/src/content/docs/docs/app/subagents.mdx +++ b/website/src/content/docs/docs/app/subagents.mdx @@ -1,6 +1,6 @@ --- title: Subagents -description: Delegate tasks with the built-in subagent tool, run them in the background, define reusable subagent profiles, and narrow what a subagent can use. +description: Delegate tasks to background subagents with the built-in subagent tool, define reusable subagent profiles, and narrow what a subagent can use. --- The Bub app includes a built-in `subagent` tool that lets the current agent delegate a specific subtask to a child run of the same agent. @@ -18,7 +18,7 @@ Use the subagent tool to review the authentication flow: 3. Return the review findings with the relevant file paths. ``` -The parent agent then calls the `subagent` tool; the child calls tools inside its own loop, finishes the analysis, and returns its final answer to the parent. Only that answer, the text of the child's last step, comes back; the child's intermediate narration and tool calls stay out of the parent's context. +The parent agent then calls the `subagent` tool, which starts the child in the background and returns a task ID at once. The child calls tools inside its own loop, finishes the analysis, and its final answer reaches the parent as a message. Only that answer, the text of the child's last step, comes back; the child's intermediate narration and tool calls stay out of the parent's context. The child sees only its `prompt` (and, with `session="fork"`, the conversation so far), so the prompt should be self-contained: say what to do, what to return, and any limits. @@ -36,16 +36,16 @@ The `session` parameter decides what the child starts from and what is kept: `inherit` is accepted as an alias of `fork`. A fork copies the conversation at the moment of the call, so the child does not see what the parent does next, and nothing the child does is written back to the parent's history. -## Run in the background {#background} +## Receive results {#background} -By default the parent waits for the subagent. With `background=true`, the tool returns a task ID at once and the parent keeps working, so several subagents can run while the parent does something else: +Subagents always run in the background: the parent keeps working while they run, so several subagents can work in parallel while the parent does something else: ```text -Start three background subagents, one per service, to check the logs for errors. +Start three subagents, one per service, to check the logs for errors. Keep working on the migration; summarize their findings when they are done. ``` -When a background task ends, its result reaches the parent session as a steering message: +When a task ends, its result reaches the parent as a message: ```text @@ -53,7 +53,7 @@ No errors in the payments service logs since the last deploy. ``` -If the parent's turn is still running, it reads the notification before its next step. If the turn already ended, the notification arrives with the session's next turn. The parent can also manage tasks directly: +The parent reads it before its next step. A turn does not end while subagents it started are still running: when the parent has nothing else to do, it waits for the next result and continues with it, so it can summarize every finding before replying. A task that outlives its turn, because the turn failed, was cancelled or reached its step limit, reports with the session's next turn. The parent can also manage tasks directly: | Tool | What it does | | --- | --- | diff --git a/website/src/content/docs/docs/app/tools.mdx b/website/src/content/docs/docs/app/tools.mdx index 43b0ef6a..93be2eaf 100644 --- a/website/src/content/docs/docs/app/tools.mdx +++ b/website/src/content/docs/docs/app/tools.mdx @@ -20,8 +20,8 @@ The Bub app ships with the following core tools: | **Session management** | `tape.info`, `tape.search` | Check token usage and search conversation records | | | `tape.handoff`, `tape.reset` | Move the context start point to compact the session, or archive and reset the context | | | `spill.read` | Read large tool results that exceed the context limit in chunks | -| **Multi-task coordination** | [`subagent`](/docs/app/subagents/) | Delegate a task to a subagent, in the foreground or in the background | -| | `subagent.wait`, `subagent.send`, `subagent.cancel` | Read background results, message a subagent, or stop it | +| **Multi-task coordination** | [`subagent`](/docs/app/subagents/) | Delegate a task to a subagent that runs in the background | +| | `subagent.wait`, `subagent.send`, `subagent.cancel` | Wait for results, message a subagent, or stop it | | **Code mode** | `run_code` | Compose multiple tools in Python when code mode is on | Built-in tool parameters and defaults are listed in the [tools reference](/docs/reference/tools/). diff --git a/website/src/content/docs/docs/reference/python-api.mdx b/website/src/content/docs/docs/reference/python-api.mdx index c360a32a..0c722881 100644 --- a/website/src/content/docs/docs/reference/python-api.mdx +++ b/website/src/content/docs/docs/reference/python-api.mdx @@ -266,7 +266,7 @@ from bub.core import EventBus, ToolCallStarted, ToolCallFinished | `RunFinished` | The Run's stream closes | `status` (`ok` / `error` / `cancelled`), `steps`, elapsed time, error | | `StepStarted` | A Step begins | `step`, optional `prompt` | | `StepFinished` | A Step ends | Progress, usage; set `next_prompt` when continuing | -| `MessagesPending` | While a request is built, or when a Step ends | Append user content to `messages` | +| `MessagesPending` | While a request is built, or when a Step ends without tool calls (`step_ended=True`) | Append user content to `messages`; at a Step end, messages continue the Run | | `LLMCallStarted` | Before calling the provider | Replace `request`; set `LLMCallDecision.finish()` | | `LLMCallFinished` | After success or an ordinary provider failure | `request` and the aggregated `result` | | `ToolCallStarted` | Before a tool runs | Replace `call`; set `deny` or `replace` | diff --git a/website/src/content/docs/docs/reference/tools.mdx b/website/src/content/docs/docs/reference/tools.mdx index 4ecc0cde..443220b1 100644 --- a/website/src/content/docs/docs/reference/tools.mdx +++ b/website/src/content/docs/docs/reference/tools.mdx @@ -156,8 +156,7 @@ Clear the current Tape. With `archive=true`, first archive the main Tape and sid ```text subagent(prompt, agent=None, description=None, model=None, reasoning_effort=None, - session="temp", allowed_tools=None, allowed_skills=None, background=False, - timeout_seconds=None) + session="temp", allowed_tools=None, allowed_skills=None, timeout_seconds=None) ``` | Parameter | Type and default | Description | @@ -170,7 +169,6 @@ subagent(prompt, agent=None, description=None, model=None, reasoning_effort=None | `session` | `str`, default `"temp"` | Session strategy for the subagent | | `allowed_tools` | `list[str] \| None`, default `None` | Tools the subagent may use; by default, the parent run's tools | | `allowed_skills` | `list[str] \| None`, default `None` | Skills the subagent may use; by default, the parent run's skills | -| `background` | `bool`, default `False` | Return a task ID at once and keep running in the background | | `timeout_seconds` | `int \| None`, default `None` | Stop the child after this many seconds; defaults to the profile's limit, then `BUB_SUBAGENT_TIMEOUT_SECONDS` | Tool names can be runtime names or model aliases, so both `fs.read` and `fs_read` work. A child never gets more than its parent: the tools default to the parent run's allowed tools, and naming a tool outside them is an error. Skill names are case-insensitive and are likewise narrowed to the parent's; `allowed_skills=[]` disables skill discovery. Children cannot start or manage subagents, so `subagent` and `subagent.*` are always excluded. @@ -197,7 +195,7 @@ The tool returns: } ``` -`output` is the child's final answer: the text of its last step that made no tool calls. A foreground subagent that fails without an answer raises a tool error. A background subagent returns at once with status `queued` or `running`; when it ends, the parent session receives its result as a steering message: +The subagent runs in the background: the tool returns at once with status `queued` or `running` and an empty `output`. When the task ends, the parent receives its result as a message: ```text @@ -205,7 +203,7 @@ The tool returns: ``` -A running parent turn reads it before its next step; otherwise it arrives with the session's next turn. Results read with `subagent.wait` send no notification. +`output` is the child's final answer: the text of its last step that made no tool calls. A run that started subagents does not end while their results are outstanding: when a step ends without tool calls, the run waits for the next task to finish and continues with its notification. A task that ends after its run, because the run failed, was cancelled or reached `max_steps`, reports through the session's steering inbox: before the next step of a running turn, or with the next turn. Results read with `subagent.wait` send no notification. At most `BUB_SUBAGENT_MAX_CONCURRENCY` subagents run at once in the process; later ones wait with status `queued`. Ending the runtime, or the `quit` command for a session, cancels running subagents. When a `temp` or `fork` subagent ends, the background shells it started are stopped. @@ -220,10 +218,10 @@ Return task results, waiting up to `timeout_seconds` for the tasks to finish; `0 ### `subagent.send` {#subagent-send} ```text -subagent.send(task_id, message, background=False) +subagent.send(task_id, message) ``` -Send a message to a task. A running task receives it before its next step. A finished task with a named session continues that conversation as a new task, bounded by the current run's tools; a finished `temp` or `fork` task cannot be continued. +Send a message to a task. A running task receives it before its next step. A finished task with a named session continues that conversation as a new background task, bounded by the current run's tools; a finished `temp` or `fork` task cannot be continued. ### `subagent.cancel` {#subagent-cancel} diff --git a/website/src/content/docs/zh-cn/docs/app/subagents.mdx b/website/src/content/docs/zh-cn/docs/app/subagents.mdx index b30135ce..4c02d972 100644 --- a/website/src/content/docs/zh-cn/docs/app/subagents.mdx +++ b/website/src/content/docs/zh-cn/docs/app/subagents.mdx @@ -1,6 +1,6 @@ --- title: 子 Agent -description: 使用内置 subagent 工具委派任务、在后台运行子任务、定义可复用的子 Agent 配置,并收窄子 Agent 的可用能力。 +description: 使用内置 subagent 工具把任务委派给后台子 Agent、定义可复用的子 Agent 配置,并收窄子 Agent 的可用能力。 --- Bub 应用内置 `subagent` 工具,允许当前 Agent 将特定子任务委派给同一个 Agent 的一次子运行。 @@ -18,7 +18,7 @@ Bub 应用内置 `subagent` 工具,允许当前 Agent 将特定子任务委派 3. 返回审查发现并附带相关文件路径。 ``` -父 Agent 随后调用 `subagent` 工具,子 Agent 在其独立循环中调用工具完成分析,最后把最终答案返回给父 Agent。返回的只有这个答案,也就是子 Agent 最后一步的文本;中间的叙述和工具调用不会进入父 Agent 的上下文。 +父 Agent 随后调用 `subagent` 工具,工具在后台启动子 Agent 并立即返回任务 ID。子 Agent 在其独立循环中调用工具完成分析,最终答案以消息的形式送达父 Agent。返回的只有这个答案,也就是子 Agent 最后一步的文本;中间的叙述和工具调用不会进入父 Agent 的上下文。 子 Agent 只能看到 `prompt`(使用 `session="fork"` 时还能看到当前对话),因此 prompt 应当自成一体:写清楚要做什么、返回什么、有哪些限制。 @@ -36,16 +36,16 @@ Bub 应用内置 `subagent` 工具,允许当前 Agent 将特定子任务委派 `inherit` 作为 `fork` 的别名仍可使用。fork 在调用发生时复制对话,子 Agent 看不到父 Agent 之后的动作,子 Agent 的记录也不会写回父会话历史。 -## 后台运行 {#background} +## 接收结果 {#background} -默认情况下父 Agent 会等待子 Agent 完成。设置 `background=true` 后,工具立即返回任务 ID,父 Agent 继续工作,多个子 Agent 可以在父 Agent 做别的事时同时运行: +子 Agent 总是在后台运行:它们运行期间父 Agent 继续工作,多个子 Agent 可以在父 Agent 做别的事时并行处理: ```text -启动三个后台子 Agent,每个服务一个,检查日志中的错误。 +启动三个子 Agent,每个服务一个,检查日志中的错误。 你继续做迁移;它们完成后汇总结论。 ``` -后台任务结束时,结果会以 steering 消息的形式送达父会话: +任务结束时,结果会以消息的形式送达父 Agent: ```text @@ -53,7 +53,7 @@ Bub 应用内置 `subagent` 工具,允许当前 Agent 将特定子任务委派 ``` -如果父 Agent 的这一轮还在进行,它会在下一步之前读到通知;如果这一轮已经结束,通知会随会话的下一轮送达。父 Agent 也可以直接管理任务: +父 Agent 会在下一步之前读到它。由它启动的子 Agent 还在运行时,这一轮不会结束:父 Agent 没有别的事可做时,会等待下一个结果并带着它继续,因此可以在回复前汇总所有结论。只有当这一轮失败、被取消或达到步数上限时,尚未结束的任务才会随会话的下一轮报告。父 Agent 也可以直接管理任务: | 工具 | 作用 | | --- | --- | diff --git a/website/src/content/docs/zh-cn/docs/app/tools.mdx b/website/src/content/docs/zh-cn/docs/app/tools.mdx index ebf5db4a..32aeac84 100644 --- a/website/src/content/docs/zh-cn/docs/app/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/app/tools.mdx @@ -20,8 +20,8 @@ description: 了解 Bub 应用内置工具、工具调用方式以及如何配 | **会话管理** | `tape.info`、`tape.search` | 查看 Token 用量、检索历史会话记录 | | | `tape.handoff`、`tape.reset` | 移动上下文起点以压缩会话,或归档并重置上下文 | | | `spill.read` | 分块读取超出上下文上限的大型工具输出 | -| **多任务协作**| [`subagent`](/zh-cn/docs/app/subagents/) | 把任务委派给子 Agent,前台等待或后台运行 | -| | `subagent.wait`、`subagent.send`、`subagent.cancel` | 读取后台结果、给子 Agent 发消息,或停止它 | +| **多任务协作**| [`subagent`](/zh-cn/docs/app/subagents/) | 把任务委派给在后台运行的子 Agent | +| | `subagent.wait`、`subagent.send`、`subagent.cancel` | 等待结果、给子 Agent 发消息,或停止它 | | **代码模式** | `run_code` | 在 代码模式 开启时通过 Python 组合多个工具 | 内置工具的详细参数及默认值见 [工具参考](/zh-cn/docs/reference/tools/)。 diff --git a/website/src/content/docs/zh-cn/docs/reference/python-api.mdx b/website/src/content/docs/zh-cn/docs/reference/python-api.mdx index 607f0893..10e3dff5 100644 --- a/website/src/content/docs/zh-cn/docs/reference/python-api.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/python-api.mdx @@ -575,7 +575,7 @@ class Event: | `RunFinished` | Run 的流关闭 | `status`(`ok` / `error` / `cancelled`)、`steps`、耗时和错误 | | `StepStarted` | Step 开始 | `step`、可选的 `prompt` | | `StepFinished` | Step 结束 | 进度、用量;继续时设置 `next_prompt` | -| `MessagesPending` | 构建请求时,或特定 Step 结束时 | 向 `messages` 追加用户内容 | +| `MessagesPending` | 构建请求时,或 Step 结束且没有工具调用时(`step_ended=True`) | 向 `messages` 追加用户内容;Step 结束时追加的消息会让 Run 继续 | | `LLMCallStarted` | 调用模型提供商之前 | 替换 `request`;设置 `LLMCallDecision.finish()` | | `LLMCallFinished` | 提供商调用成功或因普通异常失败 | `request` 和汇总后的 `result` | | `ToolCallStarted` | 工具执行之前 | 替换 `call`;设置 `deny` 或 `replace` | diff --git a/website/src/content/docs/zh-cn/docs/reference/tools.mdx b/website/src/content/docs/zh-cn/docs/reference/tools.mdx index f206d815..d8b68713 100644 --- a/website/src/content/docs/zh-cn/docs/reference/tools.mdx +++ b/website/src/content/docs/zh-cn/docs/reference/tools.mdx @@ -179,8 +179,7 @@ tape.reset(archive=False) ```text subagent(prompt, agent=None, description=None, model=None, reasoning_effort=None, - session="temp", allowed_tools=None, allowed_skills=None, background=False, - timeout_seconds=None) + session="temp", allowed_tools=None, allowed_skills=None, timeout_seconds=None) ``` | 参数 | 类型 | 说明 | @@ -193,7 +192,6 @@ subagent(prompt, agent=None, description=None, model=None, reasoning_effort=None | `session` | `str` | 子 Agent 的会话策略,默认 `"temp"` | | `allowed_tools` | `list[str] \| None` | 可用工具;默认为父 Agent 本轮的工具 | | `allowed_skills` | `list[str] \| None` | 可用技能;默认为父 Agent 本轮的技能,`[]` 禁用技能发现 | -| `background` | `bool` | 立即返回任务 ID,在后台继续运行;默认 `False` | | `timeout_seconds` | `int \| None` | 超时即停止子 Agent;默认取配置中的时限,再取 `BUB_SUBAGENT_TIMEOUT_SECONDS` | 工具名支持运行时名称和模型别名;技能名不区分大小写。子 Agent 的工具和技能不会超过父 Agent:默认就是父 Agent 本轮允许的范围,指定范围外的工具会报错。子 Agent 不能启动或管理子 Agent,`subagent` 和 `subagent.*` 始终被排除。 @@ -218,7 +216,7 @@ fork 在调用时复制父 Agent 的 Tape,子 Agent 看不到父 Agent 之后 } ``` -`output` 是子 Agent 的最终答案,即它最后一个没有工具调用的步骤的文本。前台子 Agent 失败且没有答案时抛出工具错误。后台子 Agent 立即以 `queued` 或 `running` 状态返回;结束时,父会话会收到一条 steering 消息: +子 Agent 在后台运行:工具立即以 `queued` 或 `running` 状态返回,`output` 为空。任务结束时,父 Agent 会收到一条消息: ```text @@ -226,7 +224,7 @@ fork 在调用时复制父 Agent 的 Tape,子 Agent 看不到父 Agent 之后 ``` -父 Agent 的轮次仍在进行时,会在下一步之前读到它;否则随会话的下一轮送达。用 `subagent.wait` 读过的结果不再发送通知。 +`output` 是子 Agent 的最终答案,即它最后一个没有工具调用的步骤的文本。启动了子 Agent 的运行在结果全部送达前不会结束:某一步结束且没有工具调用时,运行会等待下一个任务结束,并带着它的通知继续。如果运行失败、被取消或达到 `max_steps` 后任务才结束,结果会通过会话的 steering 收件箱送达:运行中的轮次在下一步之前读到,否则随下一轮送达。用 `subagent.wait` 读过的结果不再发送通知。 进程内同时运行的子 Agent 最多 `BUB_SUBAGENT_MAX_CONCURRENCY` 个,其余以 `queued` 状态排队。运行时退出,或对某个会话执行 `quit` 命令,会取消运行中的子 Agent。`temp` 或 `fork` 子 Agent 结束时,它启动的后台 Shell 会被停止。 @@ -241,10 +239,10 @@ subagent.wait(task_ids=None, timeout_seconds=60) ### `subagent.send` {#subagent-send} ```text -subagent.send(task_id, message, background=False) +subagent.send(task_id, message) ``` -给任务发消息。运行中的任务在下一步之前读到它。使用命名会话且已结束的任务会作为新任务继续该对话,工具范围受当前运行约束;已结束的 `temp` 或 `fork` 任务无法继续。 +给任务发消息。运行中的任务在下一步之前读到它。使用命名会话且已结束的任务会作为新的后台任务继续该对话,工具范围受当前运行约束;已结束的 `temp` 或 `fork` 任务无法继续。 ### `subagent.cancel` {#subagent-cancel}