Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7e72f1f
feat(btw): extract work execution and task state
YUZHEthefool Sep 10, 2026
8b2ba95
feat(btw): deliver and finalize detached work requests
YUZHEthefool Sep 10, 2026
d5315c7
feat(btw): submit explicit work tasks with the work command
YUZHEthefool Sep 10, 2026
97cbc40
feat(btw): show the latest work status for each session
YUZHEthefool Sep 10, 2026
2941619
chore(btw): integrate dashboard metadata fix into work runtime
YUZHEthefool Sep 10, 2026
2d1d130
fix(btw): type scheduler closure and integrate metadata repair
YUZHEthefool Sep 10, 2026
ab999c2
chore(btw): integrate CI repairs into the work command
YUZHEthefool Sep 10, 2026
0c2a8ca
chore(btw): integrate CI repairs into work status queries
YUZHEthefool Sep 10, 2026
0b17c33
fix(btw): restore Chinese work runtime labels
YUZHEthefool Sep 10, 2026
d10fd00
chore(btw): integrate Chinese labels into work delivery
YUZHEthefool Sep 10, 2026
f2656ce
chore(btw): integrate Chinese labels into work submission
YUZHEthefool Sep 10, 2026
3523fd8
chore(btw): integrate Chinese labels into work status queries
YUZHEthefool Sep 10, 2026
14895e6
feat(btw): select separate conversation and work models
YUZHEthefool Sep 10, 2026
6b3f5fd
feat(btw): constrain computer runtimes across loop handoffs
YUZHEthefool Sep 10, 2026
24d88f2
feat(btw): assign plugin tools to conversation and work loops
YUZHEthefool Sep 10, 2026
7f89d80
feat(btw): assign MCP server tools to loops
YUZHEthefool Sep 10, 2026
54987c8
feat(btw): filter Skill visibility per loop
YUZHEthefool Sep 10, 2026
832d4cd
docs(btw): define separate model routing experiment
YUZHEthefool Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions astrbot/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from astrbot.core.agent.btw.runtime_registry import (
latest_status as btw_work_latest_status,
)
from astrbot.core.agent.btw.types import (
is_work_loop_enabled as btw_work_loop_enabled,
)
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.auth import AuthContext, Decision, Resource, Role, Subject
Expand All @@ -13,6 +19,14 @@
from astrbot.core.utils.error_redaction import safe_error

_EXPORTS = {
"btw_work_latest_status": (
"astrbot.core.agent.btw.runtime_registry",
"latest_status",
),
"btw_work_loop_enabled": (
"astrbot.core.agent.btw.types",
"is_work_loop_enabled",
),
"AuthContext": ("astrbot.core.auth", "AuthContext"),
"Decision": ("astrbot.core.auth", "Decision"),
"Resource": ("astrbot.core.auth", "Resource"),
Expand Down Expand Up @@ -78,6 +92,8 @@ def __getattr__(self, item: str):
"Subject",
"ToolSet",
"agent",
"btw_work_loop_enabled",
"btw_work_latest_status",
"llm_tool",
"logger",
"safe_error",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
"desc": "AstrBot built-in session, conversation, provider, persona, plugin, and bot commands."
},
"commands": {
"work.disabled": "The BTW work loop is not enabled.",
"work.status.none": "No BTW work task has run in this session.",
"work.status.pending": "Queued: {task}",
"work.status.running": "Running: {task}",
"work.status.completed": "Completed: {task}",
"work.status.failed": "Failed: {task}",
"work.status.cancelled": "Cancelled: {task}",
"help.header": "AstrBot v{version} (WebUI: {dashboard})",
"help.empty": "No enabled built-in commands.",
"help.tip": "Tip: use `/help --image` to render the visual help card.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
"desc": "AstrBot 内置的会话、对话、Provider、Persona、插件与机器人指令。"
},
"commands": {
"work.disabled": "BTW 工作循环尚未启用。",
"work.status.none": "本会话还没有 BTW 工作任务。",
"work.status.pending": "排队中:{task}",
"work.status.running": "执行中:{task}",
"work.status.completed": "已完成:{task}",
"work.status.failed": "已失败:{task}",
"work.status.cancelled": "已取消:{task}",
"help.header": "AstrBot v{version}(WebUI:{dashboard})",
"help.empty": "当前没有已启用的内置指令。",
"help.tip": "提示:使用 `/help --image` 可生成图片版帮助。",
Expand Down
2 changes: 2 additions & 0 deletions astrbot/builtin_stars/builtin_commands/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .provider import ProviderCommands
from .session import SessionCommands
from .variable import VariableCommands
from .work import WorkCommands

__all__ = [
"AdminCommands",
Expand All @@ -24,4 +25,5 @@
"ProviderCommands",
"SessionCommands",
"VariableCommands",
"WorkCommands",
]
43 changes: 43 additions & 0 deletions astrbot/builtin_stars/builtin_commands/commands/work.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Explicit submission of free-text tasks to the BTW work loop."""

from astrbot.api import btw_work_latest_status, btw_work_loop_enabled
from astrbot.api.event import AstrMessageEvent

from .reply import reply_i18n


class WorkCommands:
"""The built-in work-loop command surface."""

def __init__(self, context) -> None:
self.context = context

async def handle(self, event: AstrMessageEvent, task: str = "") -> None:
"""Query status for empty input or ``status``, otherwise submit a task."""
stripped = (task or "").strip()
if not stripped or stripped.lower() == "status":
await self.status(event)
return
await self.submit(event, stripped)

async def status(self, event: AstrMessageEvent) -> None:
"""Show the latest task for the command's profile and message origin."""
config_id = getattr(getattr(event, "resource", None), "config_id", "") or ""
latest = await btw_work_latest_status(config_id, event.unified_msg_origin)
if latest is None:
await reply_i18n(self.context, event, "work.status.none")
return
request, status = latest
await reply_i18n(self.context, event, f"work.status.{status}", task=request)

async def submit(self, event: AstrMessageEvent, task: str) -> None:
"""Continue the admitted command event through the work loop."""
config = self.context.config.get(umo=event.unified_msg_origin)
if not btw_work_loop_enabled(config):
await reply_i18n(self.context, event, "work.disabled")
return
event.message_str = task
event.set_extra("should_run_command", False)
event.set_extra("should_run_llm", True)
event.set_extra("btw_force_work", True)
event.set_extra("btw_loop", "work")
12 changes: 12 additions & 0 deletions astrbot/builtin_stars/builtin_commands/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ProviderCommands,
SessionCommands,
VariableCommands,
WorkCommands,
)


Expand All @@ -34,6 +35,7 @@ def __init__(self, context: star.PluginContext) -> None:
self.provider_c = ProviderCommands(self.context)
self.session_c = SessionCommands(self.context)
self.variable_c = VariableCommands(self.context)
self.work_c = WorkCommands(self.context)

@filter.command("help")
async def help(
Expand Down Expand Up @@ -108,6 +110,16 @@ async def conversation_reset(self, message: AstrMessageEvent) -> None:
def task(self) -> None:
"""Manage running tasks"""

@filter.permission("session.read")
@filter.command("work")
async def work(
self,
event: AstrMessageEvent,
task: GreedyStr = GreedyStr(""),
) -> None:
"""Submit a BTW work task, or show the latest status"""
await self.work_c.handle(event, task)

@filter.permission("session.manage")
@task.command("stop")
async def task_stop(self, message: AstrMessageEvent) -> None:
Expand Down
1 change: 1 addition & 0 deletions astrbot/core/agent/btw/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# BTW runtime primitives; keep package imports inert.
50 changes: 50 additions & 0 deletions astrbot/core/agent/btw/i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Locale-aware user-facing strings for the BTW work loop.

The work loop runs in core without a plugin context, so it resolves the
locale from the event extra/session the same way ``PluginContext._locale``
does, then looks the string up in these bundles. Missing locales fall back
to ``zh-CN``.
"""

LOCALES: dict[str, dict[str, str]] = {
"zh-CN": {
"btw.work.started": "🔧 工作任务已开始处理。",
"btw.work.status.pending": "工作任务正在排队。",
"btw.work.status.running": "工作任务正在执行。",
"btw.work.status.completed": "工作任务已完成。",
"btw.work.status.failed": "工作任务执行失败。",
"btw.work.status.cancelled": "工作任务已取消。",
},
"en-US": {
"btw.work.started": "🔧 Work task started.",
"btw.work.status.pending": "The work task is queued.",
"btw.work.status.running": "The work task is running.",
"btw.work.status.completed": "The work task is completed.",
"btw.work.status.failed": "The work task failed.",
"btw.work.status.cancelled": "The work task was cancelled.",
},
}

_FALLBACK_LOCALE = "zh-CN"


def resolve_event_locale(event) -> str:
"""Return the locale for an event (extra first, then the stored session)."""
getter = getattr(event, "get_extra", None)
if callable(getter):
try:
extra = getter("locale")
except Exception: # noqa: BLE001
extra = None
if extra:
return str(extra)
return _FALLBACK_LOCALE


def text(locale: str, key: str) -> str:
"""Return one BTW string for a locale, falling back to zh-CN then key."""
bundle = LOCALES.get(locale) or LOCALES[_FALLBACK_LOCALE]
value = bundle.get(key)
if value is None:
value = LOCALES[_FALLBACK_LOCALE].get(key, key)
return value
38 changes: 38 additions & 0 deletions astrbot/core/agent/btw/loop_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Resolve capability assignments shared by BTW request paths."""


def route_is_available_in_loop(
routes: object,
*,
route_key: str,
route_id: str,
loop_mode: str,
default_loop: str = "both",
) -> bool:
"""Resolve a list assignment, falling back to the capability's default.

Args:
routes: List of dictionaries containing the capability key and ``loop``.
route_key: Key identifying a capability in an assignment.
route_id: Capability identifier to look up.
loop_mode: Current loop; missing or invalid values mean conversation.
default_loop: Assignment used for missing or malformed entries.

Returns:
Whether the capability is available in the current loop.
"""
loop_mode = "work" if loop_mode == "work" else "conversation"
route = default_loop
if isinstance(routes, list):
for entry in routes:
if not isinstance(entry, dict) or entry.get(route_key) != route_id:
continue
candidate = entry.get("loop")
if isinstance(candidate, str) and candidate in {
"conversation",
"work",
"both",
}:
route = candidate
break
return route in {"both", loop_mode}
35 changes: 35 additions & 0 deletions astrbot/core/agent/btw/runtime_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Computer runtime selection for BTW requests and their handoffs."""

from collections.abc import Mapping


def resolve_computer_runtime(
profile: Mapping,
loop: object,
inherited: str,
) -> str:
"""Resolve the runtime without granting any tool authorization.

Args:
profile: The current configuration profile.
loop: The event's loop marker; only ``work`` selects the work loop.
inherited: The runtime selected before applying BTW settings.

Returns:
The inherited runtime when BTW is disabled, otherwise the permitted
runtime for this loop.
"""
btw = profile.get("btw", {})
if not isinstance(btw, Mapping) or not btw.get("enabled", False):
return inherited
if loop != "work":
return "none"
work = btw.get("work_loop", {})
runtime = (
work.get("computer_use_runtime", "inherit")
if isinstance(work, Mapping)
else "inherit"
)
if runtime not in ("none", "local", "sandbox"):
runtime = inherited
return runtime if runtime in ("none", "local", "sandbox") else "none"
38 changes: 38 additions & 0 deletions astrbot/core/agent/btw/runtime_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Expose each pipeline's most recent work status to built-in commands."""

from .types import WorkSessionStatus
from .work_sessions import WorkSessionManager

_managers: dict[str, WorkSessionManager] = {}


def register(config_id: str, manager: WorkSessionManager) -> None:
"""Bind a successfully initialized pipeline's work-session manager."""
_managers[config_id] = manager


def unregister(config_id: str, manager: WorkSessionManager) -> None:
"""Remove only the closing pipeline's registration."""
if _managers.get(config_id) is manager:
_managers.pop(config_id)


async def latest_status(
config_id: str, origin: str
) -> tuple[str, WorkSessionStatus] | None:
"""Read the newest work task within the specified profile and origin.

Args:
config_id: The profile that owns the admitted command event.
origin: The event's unified message origin.

Returns:
The task text and status, or ``None`` when no retained task exists.
"""
manager = _managers.get(config_id)
if manager is None:
return None
session = await manager.get_for_origin(origin)
if session is None:
return None
return session.request, session.status
65 changes: 65 additions & 0 deletions astrbot/core/agent/btw/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Types shared by the BTW conversation and work loops."""

from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from uuid import uuid4


def is_work_loop_enabled(config: object) -> bool:
"""Return whether the profile explicitly enables BTW and work."""
if not isinstance(config, Mapping):
return False
btw = config.get("btw", {})
if not isinstance(btw, Mapping) or not btw.get("enabled", False):
return False
work = btw.get("work_loop", {})
return isinstance(work, Mapping) and bool(work.get("enabled", False))


class TaskType(StrEnum):
"""The execution loop selected for a user request."""

CONVERSATION = "conversation"
WORK = "work"


class WorkSessionStatus(StrEnum):
"""Lifecycle states for one work-loop request."""

PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"


@dataclass(slots=True)
class WorkSession:
"""Runtime state shared by the conversation and work loops."""

origin: str
request: str
task_type: TaskType = TaskType.WORK
id: str = field(default_factory=lambda: uuid4().hex)
status: WorkSessionStatus = WorkSessionStatus.PENDING
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
error: str | None = None

def update_status(
self,
status: WorkSessionStatus,
*,
error: str | None = None,
) -> None:
"""Record a status transition.

Args:
status: The new work-session status.
error: A safe diagnostic for failed work, when available.
"""
self.status = status
self.error = error
self.updated_at = datetime.now(UTC)
Loading
Loading