feat(outlook): 添加Outlook集成功能支持

添加完整的Outlook MCP集成,包括邮件和日历功能,通过AuthZ模式进行认证和权限管理,
支持邮箱连接、断开、状态检查和数据同步等功能。

fix(config): 统一配置文件路径从.nanobot到.beaver

将配置文件路径从/root/.nanobot统一更改为/root/.beaver,更新Dockerfile中的环境变量定义,
确保所有组件使用一致的配置目录结构。

feat(agent): 添加代理删除功能和助手身份提示

为代理注册表添加delete_agent方法,实现代理的动态删除功能;同时添加海狸助手身份提示,
确保AI助手在交互中保持一致的身份认知。

feat(engine): 增强引擎循环并添加意图决策快照

扩展AgentLoop类,添加intent_agent_decision参数用于意图驱动的代理决策,并在会话中记录
决策快照,便于后续分析和调试。

feat(authz): 扩展认证客户端功能

为AuthzClient添加设置权限、用户注册、后端注册和Outlook设置管理等新方法,增强系统
的认证和授权能力。
This commit is contained in:
2026-05-14 16:01:46 +08:00
parent 30ab74ffb2
commit ebfa242862
35 changed files with 3979 additions and 462 deletions

View File

@ -59,6 +59,17 @@ class AgentRegistry:
return agent
raise ValueError(f"Unknown agent_id: {agent_id}")
def delete_agent(self, agent_id: str) -> bool:
target = agent_id.strip()
if not target:
return False
agents = self.list_agents()
kept = [agent for agent in agents if agent.agent_id != target]
if len(kept) == len(agents):
return False
self._write_agents(kept)
return True
def search(
self,
*,

View File

@ -0,0 +1,220 @@
"""Persistent local sub-agent storage for the web UI."""
from __future__ import annotations
import json
import re
import shutil
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from beaver.coordinator.registry import AgentRegistry
_INVALID_ID_RE = re.compile(r"[^a-z0-9-]+")
def normalize_subagent_id(value: str) -> str:
normalized = _INVALID_ID_RE.sub("-", str(value or "").strip().lower()).strip("-")
normalized = re.sub(r"-{2,}", "-", normalized)
if not normalized:
raise ValueError("Sub-agent id is required")
return normalized
@dataclass(slots=True)
class SubagentSpec:
id: str
name: str
description: str
enabled: bool = True
workspace: str = ""
system_prompt: str = ""
model: str | None = None
delegation_mode: str = "remote_a2a_only"
allow_mcp: bool = True
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, payload: dict[str, Any], *, workspace_path: Path | None = None) -> "SubagentSpec":
agent_id = normalize_subagent_id(str(payload.get("id") or ""))
name = str(payload.get("name") or agent_id).strip() or agent_id
description = str(payload.get("description") or name).strip() or name
workspace = str(payload.get("workspace") or "").strip()
if not workspace and workspace_path is not None:
workspace = str(workspace_path)
mcp_servers = payload.get("mcp_servers", {})
metadata = payload.get("metadata", {})
return cls(
id=agent_id,
name=name,
description=description,
enabled=bool(payload.get("enabled", True)),
workspace=workspace,
system_prompt=str(payload.get("system_prompt") or "").strip(),
model=(str(payload.get("model") or "").strip() or None),
delegation_mode=str(payload.get("delegation_mode") or "remote_a2a_only").strip() or "remote_a2a_only",
allow_mcp=bool(payload.get("allow_mcp", True)),
tags=_string_list(payload.get("tags")),
aliases=_string_list(payload.get("aliases")),
mcp_servers=mcp_servers if isinstance(mcp_servers, dict) else {},
metadata=metadata if isinstance(metadata, dict) else {},
)
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
if not self.model:
payload["model"] = None
return payload
class LocalSubagentStore:
"""Persist sub-agent definitions under `<workspace>/agents/<id>_agent/`."""
def __init__(self, workspace: Path, *, public_base_url: str = "") -> None:
self.workspace = workspace.expanduser().resolve()
self.directory = self.workspace / "agents"
self.public_base_url = public_base_url.rstrip("/")
def list_subagents(self) -> list[SubagentSpec]:
if not self.directory.exists():
return []
result: list[SubagentSpec] = []
for child in sorted(self.directory.iterdir()):
agents_json = child / "AGENTS.json"
if not child.is_dir() or not agents_json.exists():
continue
try:
payload = json.loads(agents_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
continue
if isinstance(payload, dict):
result.append(SubagentSpec.from_dict(payload, workspace_path=child))
return result
def get_subagent(self, agent_id: str) -> SubagentSpec | None:
path = self.agents_json_path(agent_id)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
return SubagentSpec.from_dict(payload, workspace_path=self.subagent_dir(agent_id))
def upsert_subagent(self, payload: dict[str, Any]) -> SubagentSpec:
agent_id = normalize_subagent_id(str(payload.get("id") or ""))
workspace_path = self.subagent_dir(agent_id)
spec = SubagentSpec.from_dict(payload, workspace_path=workspace_path)
self._ensure_workspace(workspace_path)
spec.workspace = str(workspace_path)
self._sync_agents_md(workspace_path, spec)
self.agents_json_path(agent_id).write_text(
json.dumps(spec.to_dict(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
AgentRegistry(self.workspace).upsert_agent(self.build_registry_record(spec))
return spec
def delete_subagent(self, agent_id: str) -> bool:
agent_id = normalize_subagent_id(agent_id)
target = self.subagent_dir(agent_id)
if not target.exists():
return False
AgentRegistry(self.workspace).delete_agent(agent_id)
shutil.rmtree(target)
return True
def subagent_dir(self, agent_id: str) -> Path:
return self.directory / f"{normalize_subagent_id(agent_id)}_agent"
def agents_json_path(self, agent_id: str) -> Path:
return self.subagent_dir(agent_id) / "AGENTS.json"
def local_base_url(self, agent_id: str) -> str:
if self.public_base_url:
return f"{self.public_base_url}/subagents/{normalize_subagent_id(agent_id)}"
return f"/subagents/{normalize_subagent_id(agent_id)}"
def build_registry_record(self, spec: SubagentSpec) -> dict[str, Any]:
base_url = self.local_base_url(spec.id)
return {
"agent_id": spec.id,
"name": spec.id,
"display_name": spec.name,
"role": spec.description,
"description": spec.description,
"system_prompt": spec.system_prompt,
"model": spec.model,
"tags": sorted(set(["local-subagent", *spec.tags])),
"status": "active" if spec.enabled else "disabled",
"source": "workspace",
"metadata": {
**spec.metadata,
"workspace": spec.workspace,
"managed_by": "subagent-manager",
"local_subagent": True,
"kind": "local_subagent",
"protocol": "a2a",
"base_url": base_url,
"endpoint": f"{base_url}/rpc",
"card_url": f"{base_url}/.well-known/agent-card",
"aliases": sorted(set([spec.name, *spec.aliases])),
},
}
def serialize(self, spec: SubagentSpec) -> dict[str, Any]:
base_url = self.local_base_url(spec.id)
return {
**spec.to_dict(),
"base_url": base_url,
"endpoint": f"{base_url}/rpc",
"card_url": f"{base_url}/.well-known/agent-card",
}
def _ensure_workspace(self, workspace_path: Path) -> None:
workspace_path.mkdir(parents=True, exist_ok=True)
(workspace_path / "memory").mkdir(exist_ok=True)
(workspace_path / "skills").mkdir(exist_ok=True)
def _sync_agents_md(self, workspace_path: Path, spec: SubagentSpec) -> None:
(workspace_path / "AGENTS.md").write_text(self._render_agents_md(spec), encoding="utf-8")
@staticmethod
def _render_agents_md(spec: SubagentSpec) -> str:
prompt = spec.system_prompt.strip() or "Complete delegated tasks accurately and concisely."
return f"""# {spec.name}
You are {spec.name}, a persistent local sub-agent managed by Beaver.
## Role
{spec.description}
## System Prompt
{prompt}
## Constraints
- Work only inside this workspace.
- Respond only to delegated tasks.
- Do not create or manage local sub-agents.
- Do not message end users directly.
"""
def _string_list(value: Any) -> list[str]:
if isinstance(value, str):
value = [item.strip() for item in value.split(",")]
if not isinstance(value, list):
return []
result: list[str] = []
for item in value:
text = str(item).strip()
if text and text not in result:
result.append(text)
return result

View File

@ -29,6 +29,13 @@ from typing import Any
from beaver.memory.curated.snapshot import MemorySnapshot
BEAVER_USER_ASSISTANT_IDENTITY_PROMPT = (
"You are 海狸 (Beaver), an AI assistant developed by 博维资讯系统有限公司. "
"When communicating with users, keep this identity consistent. "
"If users ask who you are, say that you are 海狸 (Beaver), 博维资讯系统有限公司研发的 AI 助手."
)
@dataclass(slots=True)
class SkillContext:
"""单个已激活 skill 的最小表示。
@ -133,11 +140,12 @@ class ContextBuilder:
顺序固定非常重要,当前约定是:
1. base system prompt
2. session metadata
3. execution context
4. frozen memory snapshot
5. extra sections
1. Beaver user-facing assistant identity
2. base system prompt
3. session metadata
4. execution context
5. frozen memory snapshot
6. extra sections
这样设计的原因:
- 身份与总规则要最靠前
@ -146,7 +154,7 @@ class ContextBuilder:
- activated skill 正文按 Hermes 风格放到显式消息里,避免 system prompt 持续膨胀
"""
sections: list[str] = []
sections: list[str] = [BEAVER_USER_ASSISTANT_IDENTITY_PROMPT]
base_system_prompt = (build_input.base_system_prompt or "").strip()
if base_system_prompt:

View File

@ -217,6 +217,7 @@ class AgentLoop:
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
) -> AgentRunResult:
"""跑通最小 direct run 主链。
@ -265,6 +266,7 @@ class AgentLoop:
pinned_skill_names=pinned_skill_names,
pinned_skill_contexts=pinned_skill_contexts,
allow_candidate_generation=allow_candidate_generation,
intent_agent_decision=intent_agent_decision,
)
async def _process_direct_impl(
@ -301,6 +303,7 @@ class AgentLoop:
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。
@ -381,6 +384,7 @@ class AgentLoop:
"parent_session_id": parent_session_id,
"pinned_skill_names": list(pinned_skill_names or []),
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
"intent_agent_decision": intent_agent_decision,
},
content=task,
context_visible=False,
@ -389,6 +393,20 @@ class AgentLoop:
model=resolved_model,
user_id=user_id,
)
if intent_agent_decision:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="intent_agent_decision_snapshotted",
event_payload=dict(intent_agent_decision),
content=str(intent_agent_decision.get("choice") or ""),
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
user_message_recorded = False
iterations = 0

View File

@ -48,3 +48,64 @@ class AuthzClient:
async def get_permissions(self, backend_id: str) -> dict[str, Any]:
data = await self._request("GET", f"/backends/{backend_id}/permissions")
return data if isinstance(data, dict) else {}
async def set_permissions(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
data = await self._request("POST", f"/backends/{backend_id}/permissions", json_body=payload)
return data if isinstance(data, dict) else {}
async def register_user(
self,
*,
username: str,
password: str,
email: str | None = None,
backend_name: str | None = None,
backend_id: str | None = None,
base_url: str | None = None,
frontend_base_url: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"username": username,
"password": password,
}
optional = {
"email": email,
"backend_name": backend_name,
"backend_id": backend_id,
"base_url": base_url,
"frontend_base_url": frontend_base_url,
}
payload.update({key: value for key, value in optional.items() if value})
data = await self._request("POST", "/oauth/register", json_body=payload)
return data if isinstance(data, dict) else {}
async def register_backend(
self,
*,
name: str,
base_url: str,
frontend_base_url: str | None = None,
backend_id: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"name": name,
"base_url": base_url,
}
if frontend_base_url:
payload["frontend_base_url"] = frontend_base_url
if backend_id:
payload["backend_id"] = backend_id
data = await self._request("POST", "/backends/register", json_body=payload)
return data if isinstance(data, dict) else {}
async def get_outlook_settings(self, backend_id: str) -> dict[str, Any]:
data = await self._request("GET", f"/backends/{backend_id}/settings/outlook")
return data if isinstance(data, dict) else {}
async def set_outlook_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
data = await self._request("POST", f"/backends/{backend_id}/settings/outlook", json_body=payload)
return data if isinstance(data, dict) else {}
async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]:
data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook")
return data if isinstance(data, dict) else {}

View File

@ -1,2 +1,527 @@
"""Outlook integration."""
"""Workspace-scoped Outlook helpers for the web UI."""
from __future__ import annotations
import asyncio
import json
import os
import shlex
from contextlib import AsyncExitStack
from dataclasses import asdict, dataclass
from datetime import datetime, time, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from beaver.foundation.config import BeaverConfig
from beaver.integrations.authz import AuthzClient
OUTLOOK_SERVER_ID = os.getenv("BEAVER_OUTLOOK_MCP_SERVER_ID") or os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
OUTLOOK_MAX_PAGE_SIZE = 100
class OutlookIntegrationError(RuntimeError):
"""Raised when the Outlook integration backend is unavailable or misconfigured."""
@dataclass(frozen=True)
class OutlookDefaults:
domain: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_DOMAIN", "")
service_endpoint: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_URL", "")
server: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER", "")
default_timezone: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai")
autodiscover: bool = os.getenv("NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1"
@dataclass(frozen=True)
class OutlookConnectionInput:
email: str
password: str
username: str | None = None
domain: str | None = None
service_endpoint: str | None = None
server: str | None = None
autodiscover: bool = False
default_timezone: str = "Asia/Shanghai"
OUTLOOK_TOOL_NAMES = [
"auth_status",
"mail_list_folders",
"mail_list_messages",
"mail_search_messages",
"mail_get_message",
"mail_send_email",
"mail_reply_to_message",
"mail_forward_message",
"mail_move_message",
"mail_delta_sync",
"calendar_list_events",
"calendar_create_event",
"calendar_update_event",
"calendar_get_schedule",
"calendar_find_meeting_times",
"calendar_delta_sync",
]
def _call_timeout_seconds() -> float:
raw = os.getenv("NANOBOT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
try:
return max(1.0, float(raw)) if raw else 10.0
except ValueError:
return 10.0
def _use_authz_mode(config: BeaverConfig) -> bool:
return bool(config.authz.enabled and config.authz.base_url.strip())
def _authz_client(config: BeaverConfig) -> AuthzClient:
if not _use_authz_mode(config):
raise OutlookIntegrationError("AuthZ mode is not enabled.")
return AuthzClient(config.authz.base_url, timeout_seconds=int(config.authz.request_timeout_seconds))
def _require_backend_identity(config: BeaverConfig) -> str:
backend_id = config.backend_identity.backend_id.strip()
client_id = config.backend_identity.client_id.strip()
client_secret = config.backend_identity.client_secret.strip()
if not (backend_id and client_id and client_secret):
raise OutlookIntegrationError("Backend is not registered with AuthZ yet.")
return backend_id
def _outlook_mcp_url(config: BeaverConfig) -> str:
url = config.authz.outlook_mcp_url.strip()
if not url:
raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.")
return url
def outlook_defaults() -> dict[str, Any]:
return {
"provider": "ews",
"server_id": OUTLOOK_SERVER_ID,
"mcp_command": os.getenv("NANOBOT_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"),
"mcp_extra_args": shlex.split(os.getenv("NANOBOT_OUTLOOK_MCP_EXTRA_ARGS", "").strip()),
"fields": asdict(OutlookDefaults()),
}
def outlook_mcp_config_payload(config: BeaverConfig) -> dict[str, Any]:
url = _outlook_mcp_url(config)
return {
"url": url,
"authMode": "oauth_backend_token",
"authAudience": f"mcp:{OUTLOOK_SERVER_ID}",
"authScopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]],
"sensitive": True,
"toolTimeout": 60,
"kind": "online",
"category": "outlook",
"managed": True,
"displayName": "Outlook MCP",
"source": "beaver-managed",
}
def _meta_file(workspace: Path) -> Path:
return workspace.expanduser().resolve() / "state" / "bw_outlook_mcp" / "ui_meta.json"
def _load_meta(workspace: Path) -> dict[str, Any]:
path = _meta_file(workspace)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]:
payload = _load_meta(workspace)
payload.update(fields)
payload["updated_at"] = datetime.now().isoformat()
path = _meta_file(workspace)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return payload
def _normalize_input(data: OutlookConnectionInput) -> OutlookConnectionInput:
email = data.email.strip()
password = data.password
username = (data.username or "").strip() or email.partition("@")[0].strip()
domain = (data.domain or "").strip() or None
service_endpoint = (data.service_endpoint or "").strip() or None
server = (data.server or "").strip() or None
default_timezone = (data.default_timezone or "").strip() or OutlookDefaults.default_timezone
if service_endpoint:
server = None
if not email:
raise OutlookIntegrationError("Email is required.")
if not password:
raise OutlookIntegrationError("Password is required.")
if not username:
raise OutlookIntegrationError("Username is required.")
if not data.autodiscover and not service_endpoint and not server:
raise OutlookIntegrationError("Provide an EWS URL, a server host, or enable autodiscover.")
return OutlookConnectionInput(
email=email,
password=password,
username=username,
domain=domain,
service_endpoint=service_endpoint,
server=server,
autodiscover=bool(data.autodiscover),
default_timezone=default_timezone,
)
def _default_outlook_permissions() -> dict[str, Any]:
return {
"mcp": {
OUTLOOK_SERVER_ID: {
"enabled": True,
"tools": list(OUTLOOK_TOOL_NAMES),
}
},
"a2a": {"enabled": False, "agents": []},
}
async def ensure_outlook_authz_permissions(config: BeaverConfig) -> None:
backend_id = _require_backend_identity(config)
client = _authz_client(config)
existing = await client.get_permissions(backend_id)
mcp_settings = existing.get("mcp", {}).get(OUTLOOK_SERVER_ID, {}) if isinstance(existing, dict) else {}
if isinstance(mcp_settings, dict) and mcp_settings.get("enabled"):
return
await client.set_permissions(backend_id, _default_outlook_permissions())
async def _call_outlook_mcp_tool(
config: BeaverConfig,
tool_name: str,
arguments: dict[str, Any],
*,
scopes: list[str] | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
from mcp import ClientSession, types
from mcp.client.streamable_http import streamable_http_client
url = _outlook_mcp_url(config)
client = _authz_client(config)
try:
token_response = await client.issue_token(
client_id=config.backend_identity.client_id,
client_secret=config.backend_identity.client_secret,
audience=f"mcp:{OUTLOOK_SERVER_ID}",
scopes=scopes or ["list_tools", f"tool:{tool_name}"],
)
except httpx.TimeoutException as exc:
raise OutlookIntegrationError("AuthZ token 请求超时。") from exc
except httpx.HTTPError as exc:
detail = str(exc).strip() or exc.__class__.__name__
raise OutlookIntegrationError(f"AuthZ token 获取失败:{detail}") from exc
access_token = str(token_response.get("access_token") or "").strip()
if not access_token:
raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.")
async def _invoke() -> dict[str, Any]:
async with AsyncExitStack() as stack:
http_client = await stack.enter_async_context(
httpx.AsyncClient(
headers={"Authorization": f"Bearer {access_token}"},
follow_redirects=True,
trust_env=False,
timeout=timeout_seconds or _call_timeout_seconds(),
)
)
read, write, _ = await stack.enter_async_context(streamable_http_client(url, http_client=http_client))
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
result = await session.call_tool(tool_name, arguments=arguments)
parts: list[str] = []
for block in result.content:
parts.append(block.text if isinstance(block, types.TextContent) else str(block))
output = "\n".join(parts).strip()
if not output:
return {}
try:
parsed = json.loads(output)
except json.JSONDecodeError:
return {"text": output}
return parsed if isinstance(parsed, dict) else {"value": parsed}
timeout_value = timeout_seconds or _call_timeout_seconds()
try:
return await asyncio.wait_for(_invoke(), timeout=timeout_value)
except TimeoutError as exc:
raise OutlookIntegrationError(f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s") from exc
except OutlookIntegrationError:
raise
except Exception as exc:
detail = str(exc).strip() or exc.__class__.__name__
raise OutlookIntegrationError(f"Outlook MCP 调用失败:{detail}") from exc
async def test_connection(data: OutlookConnectionInput, config: BeaverConfig) -> dict[str, Any]:
if not _use_authz_mode(config):
raise OutlookIntegrationError("Outlook setup requires AuthZ mode in this Beaver instance.")
normalized = _normalize_input(data)
return {
"ok": True,
"provider": "ews",
"mailbox": normalized.email,
"resolved_username": normalized.username or "",
"resolved_domain": normalized.domain,
"sample": {"folders": [], "inbox": [], "events": []},
"warnings": [
"AuthZ mode skips local EWS validation. Credentials will be validated by the Outlook MCP service after save."
],
}
async def connect_workspace(config: BeaverConfig, workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]:
probe = await test_connection(data, config)
normalized = _normalize_input(data)
backend_id = _require_backend_identity(config)
client = _authz_client(config)
await client.set_outlook_settings(
backend_id,
{
"configured": True,
"email": normalized.email,
"username": normalized.username,
"domain": normalized.domain,
"service_endpoint": normalized.service_endpoint,
"server": normalized.server,
"autodiscover": normalized.autodiscover,
"default_timezone": normalized.default_timezone,
"password": normalized.password,
},
)
await ensure_outlook_authz_permissions(config)
meta = _update_meta(
workspace,
provider="ews",
mailbox=normalized.email,
last_verified_at=datetime.now().isoformat(),
last_connected_at=datetime.now().isoformat(),
)
return {
"ok": True,
"probe": probe["sample"],
"saved": {"backend_id": backend_id, "configured": True},
"mcp": {"id": OUTLOOK_SERVER_ID, **outlook_mcp_config_payload(config)},
"meta": meta,
}
async def disconnect_workspace(config: BeaverConfig) -> dict[str, Any]:
backend_id = _require_backend_identity(config)
removed = False
try:
result = await _authz_client(config).delete_outlook_settings(backend_id)
removed = bool(result.get("ok"))
except Exception:
removed = False
return {"ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID}
async def outlook_status(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
meta = _load_meta(workspace)
if not _use_authz_mode(config):
return {
"configured": False,
"connected": False,
"provider": None,
"storage_mode": "workspace",
"saved": None,
"auth_status": None,
"mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers,
"mcp_server_id": OUTLOOK_SERVER_ID,
"defaults": outlook_defaults(),
"meta": meta,
"error": "Outlook setup requires AuthZ mode in this Beaver instance.",
}
client = _authz_client(config)
backend_id = _require_backend_identity(config)
saved = await client.get_outlook_settings(backend_id)
configured = bool(saved.get("configured"))
connected = False
auth_status: dict[str, Any] | None = None
error: str | None = None
if configured:
try:
auth_status = await _call_outlook_mcp_tool(config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"])
connected = bool(auth_status.get("authenticated"))
except Exception as exc:
error = str(exc)
return {
"configured": configured,
"connected": connected,
"provider": "ews" if configured else None,
"storage_mode": "authz",
"saved": saved if configured else None,
"auth_status": auth_status,
"mcp_registered": bool(OUTLOOK_SERVER_ID in config.tools.mcp_servers or config.authz.outlook_mcp_url.strip()),
"mcp_server_id": OUTLOOK_SERVER_ID,
"defaults": outlook_defaults(),
"meta": meta,
"error": error,
}
async def get_overview(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
saved = await _authz_client(config).get_outlook_settings(_require_backend_identity(config))
if not saved.get("configured"):
raise OutlookIntegrationError("Outlook is not configured for this backend.")
timezone_name = str(saved.get("default_timezone") or "Asia/Shanghai")
now = datetime.now(ZoneInfo(timezone_name))
start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo)
end_of_day = start_of_day + timedelta(days=1)
warnings: list[str] = []
async def _load_section(label: str, coro: Any) -> dict[str, Any]:
try:
payload = await coro
return payload if isinstance(payload, dict) else {"value": []}
except Exception as exc:
warnings.append(f"{label} unavailable: {exc}")
return {"value": []}
inbox, sent, calendar = await asyncio.gather(
_load_section(
"inbox",
_call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
scopes=["list_tools", "tool:mail_list_messages"],
),
),
_load_section(
"sent items",
_call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
scopes=["list_tools", "tool:mail_list_messages"],
),
),
_load_section(
"calendar",
_call_outlook_mcp_tool(
config,
"calendar_list_events",
{
"start_time": start_of_day.isoformat(),
"end_time": end_of_day.isoformat(),
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
"skip": 0,
},
scopes=["list_tools", "tool:calendar_list_events"],
),
),
)
meta = _update_meta(workspace, last_overview_refresh_at=datetime.now().isoformat())
return {
"mailbox": saved.get("email") or "",
"timezone": timezone_name,
"today": now.date().isoformat(),
"connection": await outlook_status(config, workspace),
"recentInbox": inbox.get("value", []),
"recentSent": sent.get("value", []),
"todayEvents": calendar.get("value", []),
"warnings": warnings,
"meta": meta,
}
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
return max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)), max(0, int(skip))
def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]:
items = payload.get("value", []) if isinstance(payload, dict) else []
returned = len(items) if isinstance(items, list) else 0
page = payload.get("page") if isinstance(payload, dict) else None
if isinstance(page, dict):
return {
**payload,
"page": {
"top": int(page.get("top", top)),
"skip": int(page.get("skip", skip)),
"returned": int(page.get("returned", returned)),
"has_more": bool(page.get("has_more", False)),
"next_skip": page.get("next_skip"),
},
}
return {
**payload,
"page": {
"top": top,
"skip": skip,
"returned": returned,
"has_more": returned >= top,
"next_skip": skip + returned if returned >= top else None,
},
}
async def list_messages(
config: BeaverConfig,
*,
folder: str,
top: int,
skip: int = 0,
unread_only: bool = False,
) -> dict[str, Any]:
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
payload = await _call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": folder, "top": safe_top, "skip": safe_skip, "unread_only": unread_only},
scopes=["list_tools", "tool:mail_list_messages"],
)
return {"folder": folder, "unread_only": unread_only, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
async def list_events(
config: BeaverConfig,
*,
start_time: str,
end_time: str,
top: int,
skip: int = 0,
) -> dict[str, Any]:
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
payload = await _call_outlook_mcp_tool(
config,
"calendar_list_events",
{"start_time": start_time, "end_time": end_time, "top": safe_top, "skip": safe_skip},
scopes=["list_tools", "tool:calendar_list_events"],
)
return {"start_time": start_time, "end_time": end_time, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
async def get_message_detail(config: BeaverConfig, message_id: str, *, changekey: str | None = None) -> dict[str, Any]:
return await _call_outlook_mcp_tool(
config,
"mail_get_message",
{"message_id": message_id, "changekey": changekey},
scopes=["list_tools", "tool:mail_get_message"],
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,322 @@
"""File storage and workspace browsing helpers for the web API."""
from __future__ import annotations
import json
import mimetypes
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import quote
def content_disposition(disposition: str, filename: str) -> str:
"""Build a Content-Disposition header, including RFC 5987 for non-ASCII names."""
try:
filename.encode("ascii")
return f'{disposition}; filename="{filename}"'
except UnicodeEncodeError:
utf8_quoted = quote(filename)
return f"{disposition}; filename*=UTF-8''{utf8_quoted}"
def generate_file_id() -> str:
"""Generate a short unique file id."""
return uuid.uuid4().hex[:12]
def save_file(
workspace: Path,
file_id: str,
filename: str,
content: bytes,
content_type: str,
session_id: str = "web:default",
) -> dict[str, Any]:
"""Save an uploaded attachment under workspace/files/<file_id>/."""
if not _is_safe_filename(filename):
raise ValueError(f"Invalid filename: {filename}")
file_dir = _files_dir(workspace) / file_id
file_dir.mkdir(parents=True, exist_ok=True)
file_path = file_dir / filename
file_path.write_bytes(content)
metadata = {
"file_id": file_id,
"name": filename,
"content_type": content_type,
"size": len(content),
"created_at": datetime.now(timezone.utc).isoformat(),
"session_id": session_id,
}
(file_dir / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
return metadata
def get_file_metadata(workspace: Path, file_id: str) -> dict[str, Any] | None:
"""Load attachment metadata."""
if not _is_safe_file_id(file_id):
return None
meta_path = _files_dir(workspace) / file_id / "metadata.json"
if not meta_path.exists():
return None
try:
data = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
return None
return data if isinstance(data, dict) else None
def get_file_path(workspace: Path, file_id: str) -> Path | None:
"""Resolve the stored attachment path."""
meta = get_file_metadata(workspace, file_id)
if meta is None:
return None
file_path = _files_dir(workspace) / file_id / str(meta.get("name") or "")
try:
file_path.resolve().relative_to(_files_dir(workspace).resolve())
except ValueError:
return None
return file_path if file_path.exists() and file_path.is_file() else None
def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str, Any]]:
"""List uploaded attachments, optionally filtered by session."""
files_dir = _files_dir(workspace)
result: list[dict[str, Any]] = []
for entry in sorted(files_dir.iterdir()):
if not entry.is_dir():
continue
meta_path = entry / "metadata.json"
if not meta_path.exists():
continue
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(meta, dict):
continue
if session_id and meta.get("session_id") != session_id:
continue
result.append(meta)
return result
def delete_file(workspace: Path, file_id: str) -> bool:
"""Delete a stored attachment by id."""
if not _is_safe_file_id(file_id):
return False
file_dir = _files_dir(workspace) / file_id
if not file_dir.exists():
return False
shutil.rmtree(file_dir)
return True
def browse_workspace(workspace: Path, rel_path: str = "") -> dict[str, Any]:
"""List files and directories below the workspace root."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.is_dir():
raise ValueError("Invalid directory path")
try:
entries = sorted(target.iterdir(), key=lambda entry: (not entry.is_dir(), entry.name.lower()))
except PermissionError as exc:
raise ValueError("Permission denied") from exc
items: list[dict[str, Any]] = []
for entry in entries:
if entry.name.startswith("."):
continue
rel = str(entry.relative_to(workspace))
if entry.is_dir():
items.append(
{
"name": entry.name,
"path": rel,
"type": "directory",
"size": None,
"modified": datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat(),
}
)
elif entry.is_file():
stat = entry.stat()
content_type, _ = mimetypes.guess_type(entry.name)
items.append(
{
"name": entry.name,
"path": rel,
"type": "file",
"size": stat.st_size,
"content_type": content_type or "application/octet-stream",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
)
return {
"path": str(target.relative_to(workspace)) if target != workspace else "",
"items": items,
}
def workspace_file_path(workspace: Path, rel_path: str) -> Path | None:
"""Resolve a workspace file path for download."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.is_file():
return None
return target
def workspace_file_preview(workspace: Path, rel_path: str, *, max_bytes: int = 1024 * 1024) -> dict[str, Any]:
"""Return a bounded preview payload for a workspace file."""
file_path = workspace_file_path(workspace, rel_path)
if file_path is None:
raise ValueError("File not found")
stat = file_path.stat()
content_type, _ = mimetypes.guess_type(file_path.name)
content_type = content_type or "application/octet-stream"
raw = file_path.read_bytes() if stat.st_size <= max_bytes else file_path.read_bytes()[:max_bytes]
is_binary = _is_probably_binary(raw, content_type)
content = None if is_binary else raw.decode("utf-8", errors="replace")
return {
"name": file_path.name,
"path": str(file_path.relative_to(_ensure_workspace(workspace))),
"size": stat.st_size,
"content_type": content_type,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"is_binary": is_binary,
"is_truncated": stat.st_size > max_bytes,
"content": content,
}
def save_to_workspace(workspace: Path, rel_dir: str, filename: str, content: bytes) -> dict[str, Any]:
"""Save an uploaded file to a workspace directory."""
if not filename:
raise ValueError("Invalid filename")
workspace = _ensure_workspace(workspace)
target_dir = _resolve_workspace_path(workspace, rel_dir)
if target_dir is None:
raise ValueError("Invalid directory path")
target_dir.mkdir(parents=True, exist_ok=True)
file_path = (target_dir / filename).resolve()
try:
file_path.relative_to(workspace)
except ValueError as exc:
raise ValueError("Invalid filename") from exc
file_path.write_bytes(content)
stat = file_path.stat()
content_type, _ = mimetypes.guess_type(filename)
return {
"name": filename,
"path": str(file_path.relative_to(workspace)),
"type": "file",
"size": stat.st_size,
"content_type": content_type or "application/octet-stream",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
def delete_workspace_path(workspace: Path, rel_path: str) -> bool:
"""Delete a file or directory below workspace root."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.exists() or target == workspace:
return False
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
return True
def create_workspace_dir(workspace: Path, rel_path: str) -> dict[str, Any]:
"""Create a directory below workspace root."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None or target == workspace:
raise ValueError("Invalid directory path")
target.mkdir(parents=True, exist_ok=True)
stat = target.stat()
return {
"name": target.name,
"path": str(target.relative_to(workspace)),
"type": "directory",
"size": None,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
def _files_dir(workspace: Path) -> Path:
directory = _ensure_workspace(workspace) / "files"
directory.mkdir(parents=True, exist_ok=True)
return directory
def _ensure_workspace(workspace: Path) -> Path:
root = Path(workspace).expanduser()
root.mkdir(parents=True, exist_ok=True)
return root.resolve()
def _resolve_workspace_path(workspace: Path, rel_path: str) -> Path | None:
root = _ensure_workspace(workspace)
target = (root / rel_path).resolve()
try:
target.relative_to(root)
except ValueError:
return None
return target
def _is_probably_binary(raw: bytes, content_type: str) -> bool:
if content_type.startswith("text/") or content_type in {
"application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
}:
return False
if not raw:
return False
if b"\x00" in raw[:4096]:
return True
try:
raw[:4096].decode("utf-8")
except UnicodeDecodeError:
return True
return False
def _is_safe_filename(filename: str) -> bool:
return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".")
def _is_safe_file_id(file_id: str) -> bool:
return bool(file_id) and all(char in "0123456789abcdef" for char in file_id)

View File

@ -548,8 +548,13 @@ class AgentService:
provider=router_provider,
model=getattr(router_runtime, "model", None),
recent_messages=session_manager.get_messages_as_conversation(session_id),
intent_skill=self._load_intent_agent_skill(loaded),
thinking_enabled=kwargs.get("thinking_enabled"),
)
kwargs["intent_agent_decision"] = self._intent_decision_payload(
decision,
active_task=active_task,
)
if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
active_task.metadata["short_title"] = decision.short_title
task_service.store.upsert_task(active_task)
@ -796,6 +801,28 @@ class AgentService:
raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}")
return value
@staticmethod
def _load_intent_agent_skill(loaded: Any) -> str | None:
skills_loader = getattr(loaded, "skills_loader", None)
if skills_loader is None:
return None
return skills_loader.load_skill("intent-agent-router")
@staticmethod
def _intent_decision_payload(decision: Any, *, active_task: TaskRecord | None) -> dict[str, Any]:
action = decision.action or ("create_task" if decision.is_task and active_task is None else decision.mode)
return {
"agent": "intent_agent",
"choice": action,
"mode": "task" if decision.is_task else "simple",
"reason": decision.reason,
"active_task_id": active_task.task_id if active_task is not None else None,
"starts_new_task": bool(decision.starts_new_task or (decision.is_task and active_task is None)),
"closes_task": bool(decision.closes_task),
"abandons_task": bool(decision.abandons_task),
"short_title": decision.short_title,
}
@staticmethod
def _skill_names_for_run(loaded: Any, run_id: str) -> list[str]:
store = getattr(loaded, "run_memory_store", None)

View File

@ -68,6 +68,34 @@ class SkillHubService:
files = []
return {"detail": detail, "files": files}
async def versions(self, namespace: str, slug: str) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
payload = _unwrap(await self._get_json(f"/skills/{namespace}/{slug}/versions"))
if not isinstance(payload, dict):
payload = {}
items = payload.get("items")
return {
"items": items if isinstance(items, list) else [],
"total": int(payload.get("total") or len(items or [])),
"page": int(payload.get("page") or 0),
"size": int(payload.get("size") or len(items or [])),
}
async def file_content(self, namespace: str, slug: str, version: str, file_path: str) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
safe_path = _safe_posix_path(file_path)
content = await self._get_text(
f"/skills/{namespace}/{slug}/versions/{version}/file",
params={"path": safe_path},
)
return {
"filePath": safe_path,
"content": content,
"contentType": _guess_content_type(safe_path),
"isBinary": False,
"fileSize": len(content.encode("utf-8")),
}
async def install(self, namespace: str, slug: str, version: str | None = None) -> dict[str, Any]:
namespace = namespace.removeprefix("@")
skill = await self.detail(namespace, slug)
@ -246,3 +274,14 @@ def _render_skill_content(frontmatter: dict[str, Any], body: str) -> str:
lines.append(f"{key}: {value}")
lines.extend(["---", "", body.strip()])
return "\n".join(lines).rstrip() + "\n"
def _guess_content_type(file_path: str) -> str:
lower = file_path.lower()
if lower.endswith(".md"):
return "text/markdown"
if lower.endswith(".json"):
return "application/json"
if lower.endswith((".txt", ".yaml", ".yml", ".toml", ".csv", ".log")):
return "text/plain"
return "text/plain"

View File

@ -0,0 +1,59 @@
---
name: intent-agent-router
description: Internal routing guidance for the first-layer Intent Agent.
internal: true
---
# Intent Agent Router
## Role
You are the first-layer Intent Agent. You are not the main assistant and you do not execute the user request.
Your only job is to classify the current user message into one routing decision:
- `simple_chat`
- `continue_task`
- `new_task`
- `close_task`
- `abandon_task`
Return compact JSON only. Never answer the user directly.
## Core Boundary
Choose `simple_chat` only when the message can be answered safely from ordinary language understanding without tools, external data, local files, user-private data, execution, validation, or multi-step work.
Choose `new_task` when the user asks for anything that needs the main Task agent's capabilities, including tools, skills, files, web/search, execution, iteration, planning, validation, or multi-agent work.
The Intent Agent has no tools. If a request needs a tool, do not apologize and do not say you cannot access it. Route it to Task mode so the main agent can use tools.
## Must Create Task
Choose `new_task` when there is no active task and the request asks to:
- look up, search, browse, fetch, verify, check, monitor, compare, or summarize current/external information
- answer about today's weather, live conditions, latest news, prices, schedules, exchange rates, regulations, releases, or other changing facts
- inspect, read, write, patch, run, test, build, deploy, debug, or modify local files or systems
- use email, calendar, messages, databases, MCP tools, shell commands, web APIs, or other integrations
- produce a deliverable that needs multiple steps, validation, or follow-up execution
Examples that must be `new_task`:
- "帮我查一下今天珠海天气"
- "查一下最新的 OpenAI API 价格"
- "看看这个项目测试为什么失败"
- "帮我改一下登录页面"
- "给我查一下明天的航班"
## Simple Chat
Choose `simple_chat` only for lightweight conversation or stable knowledge that does not need tools.
Examples:
- "你好"
- "解释一下什么是递归"
- "把这句话润色一下"
- "给我一个学习 Python 的大纲"
If uncertain whether tools may be needed, prefer `new_task`.

View File

@ -67,7 +67,12 @@ class SkillsLoader:
self.extra_dirs = [Path(item) for item in (extra_dirs or [])]
self.skill_store = skill_store or SkillSpecStore(self.workspace)
def list_skills(self, *, filter_unavailable: bool = True) -> list[SkillRecord]:
def list_skills(
self,
*,
filter_unavailable: bool = True,
include_internal: bool = False,
) -> list[SkillRecord]:
"""列出当前可见的 skills。
优先级:
@ -80,9 +85,11 @@ class SkillsLoader:
found: dict[str, SkillRecord] = {}
for record in self.list_published_skills():
for record in self.list_published_skills(filter_unavailable=filter_unavailable):
if record.name in found:
continue
if not include_internal and self._record_internal(record):
continue
if filter_unavailable and not self._record_available(record):
continue
found[record.name] = record
@ -101,6 +108,8 @@ class SkillsLoader:
if name in found:
continue
frontmatter, body = parse_frontmatter(skill_file.read_text(encoding="utf-8"))
if not include_internal and _truthy(frontmatter.get("internal")):
continue
normalized_frontmatter = dict(frontmatter)
record = SkillRecord(
name=name,
@ -375,11 +384,15 @@ class SkillsLoader:
return []
def _find_record(self, name: str) -> SkillRecord | None:
for record in self.list_skills(filter_unavailable=False):
for record in self.list_skills(filter_unavailable=False, include_internal=True):
if record.name == name:
return record
return None
@staticmethod
def _record_internal(record: SkillRecord) -> bool:
return _truthy((record.frontmatter or {}).get("internal"))
def _record_available(self, record: SkillRecord) -> bool:
content = record.path.read_text(encoding="utf-8")
frontmatter, _ = parse_frontmatter(content)
@ -405,3 +418,9 @@ class SkillsLoader:
def summarize_body(body: str) -> str:
cleaned = " ".join(line.strip() for line in body.splitlines()[:3] if line.strip()).strip()
return cleaned[:240]
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in {"1", "true", "yes", "y", "on"}

View File

@ -181,7 +181,7 @@ class SkillLearningService:
if candidate.kind == "new_skill":
payload = await self.synthesizer.synthesize_new_skill(candidate, packet, provider, model)
return self.draft_service.create_new_skill_draft(
skill_name=self._suggest_skill_name(candidate, packet),
skill_name=self._suggest_skill_name(candidate, packet, payload.get("frontmatter")),
proposed_content=payload["content"],
proposed_frontmatter=payload["frontmatter"],
created_by="learning-loop",
@ -382,15 +382,34 @@ class SkillLearningService:
return " ".join(words[:8]).strip()
@staticmethod
def _suggest_skill_name(candidate: SkillLearningCandidate, packet: EvidencePacket) -> str:
def _suggest_skill_name(
candidate: SkillLearningCandidate,
packet: EvidencePacket,
frontmatter: dict[str, Any] | None = None,
) -> str:
if candidate.related_skill_names:
return candidate.related_skill_names[0]
if packet.task_summaries:
seed = re.sub(r"[^a-z0-9]+", "-", packet.task_summaries[0].lower()).strip("-")
if isinstance(frontmatter, dict):
description = str(frontmatter.get("description") or "")
seed = SkillLearningService._slugify_skill_name(description)
if seed:
return seed[:48]
return seed
if packet.task_summaries:
seed = SkillLearningService._slugify_skill_name(packet.task_summaries[0])
if seed:
return seed
return f"generated-skill-{uuid4().hex[:8]}"
@staticmethod
def _slugify_skill_name(value: str) -> str:
seed = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
seed = re.sub(r"-+", "-", seed)
if not seed or seed.isdigit() or len(seed) < 3:
return ""
words = [part for part in seed.split("-") if part and not part.isdigit()]
seed = "-".join(words) or seed
return seed[:48].strip("-")
@staticmethod
def _parse_timestamp(value: str) -> datetime | None:
try:

View File

@ -163,6 +163,7 @@ class MainAgentDecision:
closes_task: bool = False
abandons_task: bool = False
short_title: str | None = None
action: str = ""
@property
def is_task(self) -> bool:

View File

@ -20,6 +20,7 @@ class MainAgentRouter:
provider: Any | None = None,
model: str | None = None,
recent_messages: list[dict[str, Any]] | None = None,
intent_skill: str | None = None,
thinking_enabled: bool | None = None,
timeout_seconds: float = 8.0,
) -> MainAgentDecision:
@ -31,8 +32,9 @@ class MainAgentRouter:
{
"role": "system",
"content": (
"You route user messages for Beaver's internal Task mode. "
"Return only compact JSON. Do not explain."
"You are Beaver's Intent Agent. Your only job is to route the user's "
"message to simple chat or internal Task mode. Return only compact JSON. "
"Do not answer the user. Do not explain."
),
},
{
@ -41,6 +43,7 @@ class MainAgentRouter:
message=message,
active_task=active_task,
recent_messages=recent_messages or [],
intent_skill=intent_skill,
),
},
],
@ -63,19 +66,48 @@ class MainAgentRouter:
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
if raw_action in {"continue_task", "continue", "task"}:
return MainAgentDecision(mode="task", reason=reason, short_title=short_title)
return MainAgentDecision(
mode="task",
reason=reason,
starts_new_task=active_task is None,
short_title=short_title,
action="continue_task" if active_task is not None else "create_task",
)
if raw_action in {"new_task", "new"}:
return MainAgentDecision(mode="task", reason=reason, starts_new_task=True, short_title=short_title)
return MainAgentDecision(
mode="task",
reason=reason,
starts_new_task=True,
short_title=short_title,
action="create_task",
)
if raw_action in {"close_task", "close", "done", "finish"}:
return MainAgentDecision(mode="simple", reason=reason, closes_task=active_task is not None, short_title=short_title)
return MainAgentDecision(
mode="simple",
reason=reason,
closes_task=active_task is not None,
short_title=short_title,
action="close_task",
)
if raw_action in {"abandon_task", "abandon", "cancel_task"}:
return MainAgentDecision(mode="simple", reason=reason, abandons_task=active_task is not None, short_title=short_title)
return MainAgentDecision(mode="simple", reason=reason or "simple_chat", short_title=short_title)
return MainAgentDecision(
mode="simple",
reason=reason,
abandons_task=active_task is not None,
short_title=short_title,
action="abandon_task",
)
return MainAgentDecision(
mode="simple",
reason=reason or "simple_chat",
short_title=short_title,
action="simple_chat",
)
def _fallback(self, *, active_task: TaskRecord | None, reason: str) -> MainAgentDecision:
if active_task is not None:
return MainAgentDecision(mode="task", reason=reason)
return MainAgentDecision(mode="simple", reason=reason)
return MainAgentDecision(mode="task", reason=reason, action="continue_task")
return MainAgentDecision(mode="simple", reason=reason, action="simple_chat")
@staticmethod
def _prompt(
@ -83,6 +115,7 @@ class MainAgentRouter:
message: str,
active_task: TaskRecord | None,
recent_messages: list[dict[str, Any]],
intent_skill: str | None,
) -> str:
active_task_payload = None
if active_task is not None:
@ -98,8 +131,14 @@ class MainAgentRouter:
for item in recent_messages[-8:]
if item.get("role") in {"user", "assistant"}
]
skill_section = (
f"Intent Agent skill guidance:\n{intent_skill.strip()}\n\n"
if intent_skill and intent_skill.strip()
else ""
)
return (
"Decide how to route the current user message.\n\n"
f"{skill_section}"
"Actions:\n"
"- simple_chat: no Task should be created or continued.\n"
"- continue_task: keep the user in the active Task.\n"
@ -113,6 +152,10 @@ class MainAgentRouter:
"- Use new_task only when the user clearly asks to start a different task.\n"
"- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, "
"implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n"
"- Requests that need current, real-time, external, user-private, local-file, web, weather, price, news, "
"calendar, email, or system data require tools. Choose new_task for them because the Intent Agent has no tools.\n"
"- The Intent Agent must never answer tool-dependent requests itself or apologize for lacking tools. "
"It only routes the request so the main Task agent can use tools.\n"
"- short_title must be 5-15 Chinese characters or a similarly short English phrase when a Task is involved.\n\n"
"Return JSON only with keys: action, reason, short_title.\n\n"
f"Active task:\n{json.dumps(active_task_payload, ensure_ascii=False)}\n\n"