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:
@ -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,
|
||||
*,
|
||||
|
||||
220
app-instance/backend/beaver/coordinator/subagents.py
Normal file
220
app-instance/backend/beaver/coordinator/subagents.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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
322
app-instance/backend/beaver/interfaces/web/files.py
Normal file
322
app-instance/backend/beaver/interfaces/web/files.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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`.
|
||||
@ -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"}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user