feat(skills): add neo lifecycle tools and stable sync manager
This commit is contained in:
@@ -21,18 +21,32 @@ from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.astr_agent_run_util import AgentRunner
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
ANNOTATE_EXECUTION_TOOL,
|
||||
BROWSER_BATCH_EXEC_TOOL,
|
||||
BROWSER_EXEC_TOOL,
|
||||
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
|
||||
CREATE_SKILL_CANDIDATE_TOOL,
|
||||
CREATE_SKILL_PAYLOAD_TOOL,
|
||||
EVALUATE_SKILL_CANDIDATE_TOOL,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
GET_EXECUTION_HISTORY_TOOL,
|
||||
GET_SKILL_PAYLOAD_TOOL,
|
||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||
LIST_SKILL_CANDIDATES_TOOL,
|
||||
LIST_SKILL_RELEASES_TOOL,
|
||||
LIVE_MODE_SYSTEM_PROMPT,
|
||||
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PROMOTE_SKILL_CANDIDATE_TOOL,
|
||||
PYTHON_TOOL,
|
||||
ROLLBACK_SKILL_RELEASE_TOOL,
|
||||
RUN_BROWSER_SKILL_TOOL,
|
||||
SANDBOX_MODE_PROMPT,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
SYNC_SKILL_RELEASE_TOOL,
|
||||
TOOL_CALL_PROMPT,
|
||||
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
|
||||
retrieve_knowledge_base,
|
||||
@@ -781,9 +795,11 @@ def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) -
|
||||
def _apply_sandbox_tools(
|
||||
config: MainAgentBuildConfig, req: ProviderRequest, session_id: str
|
||||
) -> None:
|
||||
_ = session_id
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
if config.sandbox_cfg.get("booter") == "shipyard":
|
||||
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
|
||||
if booter == "shipyard":
|
||||
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
|
||||
at = config.sandbox_cfg.get("shipyard_access_token", "")
|
||||
if not ep or not at:
|
||||
@@ -791,10 +807,28 @@ def _apply_sandbox_tools(
|
||||
return
|
||||
os.environ["SHIPYARD_ENDPOINT"] = ep
|
||||
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
|
||||
|
||||
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
|
||||
req.func_tool.add_tool(PYTHON_TOOL)
|
||||
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
|
||||
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
|
||||
|
||||
if booter == "shipyard_neo":
|
||||
req.func_tool.add_tool(BROWSER_EXEC_TOOL)
|
||||
req.func_tool.add_tool(BROWSER_BATCH_EXEC_TOOL)
|
||||
req.func_tool.add_tool(RUN_BROWSER_SKILL_TOOL)
|
||||
req.func_tool.add_tool(GET_EXECUTION_HISTORY_TOOL)
|
||||
req.func_tool.add_tool(ANNOTATE_EXECUTION_TOOL)
|
||||
req.func_tool.add_tool(CREATE_SKILL_PAYLOAD_TOOL)
|
||||
req.func_tool.add_tool(GET_SKILL_PAYLOAD_TOOL)
|
||||
req.func_tool.add_tool(CREATE_SKILL_CANDIDATE_TOOL)
|
||||
req.func_tool.add_tool(LIST_SKILL_CANDIDATES_TOOL)
|
||||
req.func_tool.add_tool(EVALUATE_SKILL_CANDIDATE_TOOL)
|
||||
req.func_tool.add_tool(PROMOTE_SKILL_CANDIDATE_TOOL)
|
||||
req.func_tool.add_tool(LIST_SKILL_RELEASES_TOOL)
|
||||
req.func_tool.add_tool(ROLLBACK_SKILL_RELEASE_TOOL)
|
||||
req.func_tool.add_tool(SYNC_SKILL_RELEASE_TOOL)
|
||||
|
||||
req.system_prompt += f"\n{SANDBOX_MODE_PROMPT}\n"
|
||||
|
||||
|
||||
|
||||
@@ -12,11 +12,25 @@ from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.computer.tools import (
|
||||
AnnotateExecutionTool,
|
||||
BrowserBatchExecTool,
|
||||
BrowserExecTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileUploadTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
LocalPythonTool,
|
||||
PromoteSkillCandidateTool,
|
||||
PythonTool,
|
||||
RollbackSkillReleaseTool,
|
||||
RunBrowserSkillTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
@@ -446,6 +460,20 @@ PYTHON_TOOL = PythonTool()
|
||||
LOCAL_PYTHON_TOOL = LocalPythonTool()
|
||||
FILE_UPLOAD_TOOL = FileUploadTool()
|
||||
FILE_DOWNLOAD_TOOL = FileDownloadTool()
|
||||
BROWSER_EXEC_TOOL = BrowserExecTool()
|
||||
BROWSER_BATCH_EXEC_TOOL = BrowserBatchExecTool()
|
||||
RUN_BROWSER_SKILL_TOOL = RunBrowserSkillTool()
|
||||
GET_EXECUTION_HISTORY_TOOL = GetExecutionHistoryTool()
|
||||
ANNOTATE_EXECUTION_TOOL = AnnotateExecutionTool()
|
||||
CREATE_SKILL_PAYLOAD_TOOL = CreateSkillPayloadTool()
|
||||
GET_SKILL_PAYLOAD_TOOL = GetSkillPayloadTool()
|
||||
CREATE_SKILL_CANDIDATE_TOOL = CreateSkillCandidateTool()
|
||||
LIST_SKILL_CANDIDATES_TOOL = ListSkillCandidatesTool()
|
||||
EVALUATE_SKILL_CANDIDATE_TOOL = EvaluateSkillCandidateTool()
|
||||
PROMOTE_SKILL_CANDIDATE_TOOL = PromoteSkillCandidateTool()
|
||||
LIST_SKILL_RELEASES_TOOL = ListSkillReleasesTool()
|
||||
ROLLBACK_SKILL_RELEASE_TOOL = RollbackSkillReleaseTool()
|
||||
SYNC_SKILL_RELEASE_TOOL = SyncSkillReleaseTool()
|
||||
|
||||
# we prevent astrbot from connecting to known malicious hosts
|
||||
# these hosts are base64 encoded
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool
|
||||
from .fs import FileDownloadTool, FileUploadTool
|
||||
from .neo_skills import (
|
||||
AnnotateExecutionTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
PromoteSkillCandidateTool,
|
||||
RollbackSkillReleaseTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
from .python import LocalPythonTool, PythonTool
|
||||
from .shell import ExecuteShellTool
|
||||
|
||||
__all__ = [
|
||||
"BrowserExecTool",
|
||||
"BrowserBatchExecTool",
|
||||
"RunBrowserSkillTool",
|
||||
"GetExecutionHistoryTool",
|
||||
"AnnotateExecutionTool",
|
||||
"CreateSkillPayloadTool",
|
||||
"GetSkillPayloadTool",
|
||||
"CreateSkillCandidateTool",
|
||||
"ListSkillCandidatesTool",
|
||||
"EvaluateSkillCandidateTool",
|
||||
"PromoteSkillCandidateTool",
|
||||
"ListSkillReleasesTool",
|
||||
"RollbackSkillReleaseTool",
|
||||
"SyncSkillReleaseTool",
|
||||
"FileUploadTool",
|
||||
"PythonTool",
|
||||
"LocalPythonTool",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
from ..computer_client import get_booter
|
||||
|
||||
|
||||
def _to_json(data: Any) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
if context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Browser and skill lifecycle tools are only allowed "
|
||||
"for admin users."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any:
|
||||
booter = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
browser = getattr(booter, "browser", None)
|
||||
if browser is None:
|
||||
raise RuntimeError(
|
||||
"Current sandbox booter does not support browser capability. "
|
||||
"Please switch to shipyard_neo."
|
||||
)
|
||||
return browser
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserExecTool(FunctionTool):
|
||||
name: str = "astrbot_execute_browser"
|
||||
description: str = "Execute one browser automation command in the sandbox."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": {"type": "string", "description": "Browser command to execute."},
|
||||
"timeout": {"type": "integer", "default": 30},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional execution description.",
|
||||
},
|
||||
"tags": {"type": "string", "description": "Optional tags."},
|
||||
"learn": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to mark execution as learn evidence.",
|
||||
"default": False,
|
||||
},
|
||||
"include_trace": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include trace_ref in response.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["cmd"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
cmd: str,
|
||||
timeout: int = 30,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
result = await browser.exec(
|
||||
cmd=cmd,
|
||||
timeout=timeout,
|
||||
description=description,
|
||||
tags=tags,
|
||||
learn=learn,
|
||||
include_trace=include_trace,
|
||||
)
|
||||
return _to_json(result)
|
||||
except Exception as e:
|
||||
return f"Error executing browser command: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserBatchExecTool(FunctionTool):
|
||||
name: str = "astrbot_execute_browser_batch"
|
||||
description: str = "Execute a browser command batch in the sandbox."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Ordered browser commands.",
|
||||
},
|
||||
"timeout": {"type": "integer", "default": 60},
|
||||
"stop_on_error": {"type": "boolean", "default": True},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional execution description.",
|
||||
},
|
||||
"tags": {"type": "string", "description": "Optional tags."},
|
||||
"learn": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to mark execution as learn evidence.",
|
||||
"default": False,
|
||||
},
|
||||
"include_trace": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to include trace_ref in response.",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["commands"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
commands: list[str],
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
result = await browser.exec_batch(
|
||||
commands=commands,
|
||||
timeout=timeout,
|
||||
stop_on_error=stop_on_error,
|
||||
description=description,
|
||||
tags=tags,
|
||||
learn=learn,
|
||||
include_trace=include_trace,
|
||||
)
|
||||
return _to_json(result)
|
||||
except Exception as e:
|
||||
return f"Error executing browser batch command: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunBrowserSkillTool(FunctionTool):
|
||||
name: str = "astrbot_run_browser_skill"
|
||||
description: str = "Run a released browser skill in the sandbox by skill_key."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_key": {"type": "string"},
|
||||
"timeout": {"type": "integer", "default": 60},
|
||||
"stop_on_error": {"type": "boolean", "default": True},
|
||||
"include_trace": {"type": "boolean", "default": False},
|
||||
"description": {"type": "string"},
|
||||
"tags": {"type": "string"},
|
||||
},
|
||||
"required": ["skill_key"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
skill_key: str,
|
||||
timeout: int = 60,
|
||||
stop_on_error: bool = True,
|
||||
include_trace: bool = False,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
result = await browser.run_skill(
|
||||
skill_key=skill_key,
|
||||
timeout=timeout,
|
||||
stop_on_error=stop_on_error,
|
||||
include_trace=include_trace,
|
||||
description=description,
|
||||
tags=tags,
|
||||
)
|
||||
return _to_json(result)
|
||||
except Exception as e:
|
||||
return f"Error running browser skill: {str(e)}"
|
||||
@@ -0,0 +1,545 @@
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
|
||||
|
||||
from ..computer_client import get_booter
|
||||
|
||||
|
||||
def _to_jsonable(model_like: Any) -> Any:
|
||||
if isinstance(model_like, dict):
|
||||
return model_like
|
||||
if isinstance(model_like, list):
|
||||
return [_to_jsonable(i) for i in model_like]
|
||||
if hasattr(model_like, "model_dump"):
|
||||
return _to_jsonable(model_like.model_dump())
|
||||
return model_like
|
||||
|
||||
|
||||
def _to_json_text(data: Any) -> str:
|
||||
return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
if context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Skill lifecycle tools are only allowed for admin users."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_neo_context(context: ContextWrapper[AstrAgentContext]) -> tuple[Any, Any]:
|
||||
booter = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
client = getattr(booter, "bay_client", None)
|
||||
sandbox = getattr(booter, "sandbox", None)
|
||||
if client is None or sandbox is None:
|
||||
raise RuntimeError(
|
||||
"Current sandbox booter does not support Neo skill lifecycle APIs. "
|
||||
"Please switch to shipyard_neo."
|
||||
)
|
||||
return client, sandbox
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetExecutionHistoryTool(FunctionTool):
|
||||
name: str = "astrbot_get_execution_history"
|
||||
description: str = "Get execution history from current sandbox."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"exec_type": {"type": "string"},
|
||||
"success_only": {"type": "boolean", "default": False},
|
||||
"limit": {"type": "integer", "default": 100},
|
||||
"offset": {"type": "integer", "default": 0},
|
||||
"tags": {"type": "string"},
|
||||
"has_notes": {"type": "boolean", "default": False},
|
||||
"has_description": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
exec_type: str | None = None,
|
||||
success_only: bool = False,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
tags: str | None = None,
|
||||
has_notes: bool = False,
|
||||
has_description: bool = False,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
_client, sandbox = await _get_neo_context(context)
|
||||
result = await sandbox.get_execution_history(
|
||||
exec_type=exec_type,
|
||||
success_only=success_only,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
tags=tags,
|
||||
has_notes=has_notes,
|
||||
has_description=has_description,
|
||||
)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error getting execution history: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnnotateExecutionTool(FunctionTool):
|
||||
name: str = "astrbot_annotate_execution"
|
||||
description: str = "Annotate one execution history record."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"execution_id": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"tags": {"type": "string"},
|
||||
"notes": {"type": "string"},
|
||||
},
|
||||
"required": ["execution_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
execution_id: str,
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
_client, sandbox = await _get_neo_context(context)
|
||||
result = await sandbox.annotate_execution(
|
||||
execution_id=execution_id,
|
||||
description=description,
|
||||
tags=tags,
|
||||
notes=notes,
|
||||
)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error annotating execution: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateSkillPayloadTool(FunctionTool):
|
||||
name: str = "astrbot_create_skill_payload"
|
||||
description: str = "Create a generic skill payload and return payload_ref."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"anyOf": [{"type": "object"}, {"type": "array"}],
|
||||
"description": (
|
||||
"Skill payload JSON. Recommended fields: skill_markdown, commands, meta."
|
||||
),
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "Payload kind.",
|
||||
"default": "astrbot_skill_v1",
|
||||
},
|
||||
},
|
||||
"required": ["payload"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
payload: dict[str, Any] | list[Any],
|
||||
kind: str = "astrbot_skill_v1",
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
result = await client.skills.create_payload(payload=payload, kind=kind)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error creating skill payload: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetSkillPayloadTool(FunctionTool):
|
||||
name: str = "astrbot_get_skill_payload"
|
||||
description: str = "Get one skill payload by payload_ref."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload_ref": {"type": "string"},
|
||||
},
|
||||
"required": ["payload_ref"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
payload_ref: str,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
result = await client.skills.get_payload(payload_ref)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error getting skill payload: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateSkillCandidateTool(FunctionTool):
|
||||
name: str = "astrbot_create_skill_candidate"
|
||||
description: str = "Create a skill candidate from source execution IDs."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_key": {"type": "string"},
|
||||
"source_execution_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"scenario_key": {"type": "string"},
|
||||
"payload_ref": {"type": "string"},
|
||||
},
|
||||
"required": ["skill_key", "source_execution_ids"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
skill_key: str,
|
||||
source_execution_ids: list[str],
|
||||
scenario_key: str | None = None,
|
||||
payload_ref: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
result = await client.skills.create_candidate(
|
||||
skill_key=skill_key,
|
||||
source_execution_ids=source_execution_ids,
|
||||
scenario_key=scenario_key,
|
||||
payload_ref=payload_ref,
|
||||
)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error creating skill candidate: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListSkillCandidatesTool(FunctionTool):
|
||||
name: str = "astrbot_list_skill_candidates"
|
||||
description: str = "List skill candidates."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string"},
|
||||
"skill_key": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 100},
|
||||
"offset": {"type": "integer", "default": 0},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
status: str | None = None,
|
||||
skill_key: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
result = await client.skills.list_candidates(
|
||||
status=status,
|
||||
skill_key=skill_key,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error listing skill candidates: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluateSkillCandidateTool(FunctionTool):
|
||||
name: str = "astrbot_evaluate_skill_candidate"
|
||||
description: str = "Evaluate a skill candidate."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidate_id": {"type": "string"},
|
||||
"passed": {"type": "boolean"},
|
||||
"score": {"type": "number"},
|
||||
"benchmark_id": {"type": "string"},
|
||||
"report": {"type": "string"},
|
||||
},
|
||||
"required": ["candidate_id", "passed"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
candidate_id: str,
|
||||
passed: bool,
|
||||
score: float | None = None,
|
||||
benchmark_id: str | None = None,
|
||||
report: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
result = await client.skills.evaluate_candidate(
|
||||
candidate_id,
|
||||
passed=passed,
|
||||
score=score,
|
||||
benchmark_id=benchmark_id,
|
||||
report=report,
|
||||
)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error evaluating skill candidate: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromoteSkillCandidateTool(FunctionTool):
|
||||
name: str = "astrbot_promote_skill_candidate"
|
||||
description: str = "Promote one candidate to release stage (canary/stable)."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"candidate_id": {"type": "string"},
|
||||
"stage": {
|
||||
"type": "string",
|
||||
"description": "Release stage: canary/stable",
|
||||
"default": "canary",
|
||||
},
|
||||
"sync_to_local": {
|
||||
"type": "boolean",
|
||||
"description": "When stage is stable, sync payload.skill_markdown to local SKILL.md.",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["candidate_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
candidate_id: str,
|
||||
stage: str = "canary",
|
||||
sync_to_local: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
if stage not in {"canary", "stable"}:
|
||||
return "Error promoting skill candidate: stage must be canary or stable."
|
||||
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
release = await client.skills.promote_candidate(candidate_id, stage=stage)
|
||||
release_json = _to_jsonable(release)
|
||||
|
||||
sync_json: dict[str, Any] | None = None
|
||||
rollback_json: dict[str, Any] | None = None
|
||||
if stage == "stable" and sync_to_local:
|
||||
sync_mgr = NeoSkillSyncManager()
|
||||
try:
|
||||
sync_result = await sync_mgr.sync_release(
|
||||
client,
|
||||
release_id=str(release_json.get("id", "")),
|
||||
require_stable=True,
|
||||
)
|
||||
sync_json = {
|
||||
"skill_key": sync_result.skill_key,
|
||||
"local_skill_name": sync_result.local_skill_name,
|
||||
"release_id": sync_result.release_id,
|
||||
"candidate_id": sync_result.candidate_id,
|
||||
"payload_ref": sync_result.payload_ref,
|
||||
"map_path": sync_result.map_path,
|
||||
"synced_at": sync_result.synced_at,
|
||||
}
|
||||
except Exception as sync_err:
|
||||
# Keep state consistent by rolling back the new release.
|
||||
try:
|
||||
rollback = await client.skills.rollback_release(
|
||||
str(release_json.get("id", ""))
|
||||
)
|
||||
rollback_json = _to_jsonable(rollback)
|
||||
except Exception as rollback_err:
|
||||
return (
|
||||
"Error promoting skill candidate: stable release synced failed; "
|
||||
f"auto rollback also failed. sync_error={sync_err}; "
|
||||
f"rollback_error={rollback_err}"
|
||||
)
|
||||
return (
|
||||
"Error promoting skill candidate: stable release synced failed; "
|
||||
f"auto rollback succeeded. sync_error={sync_err}; "
|
||||
f"rollback={_to_json_text(rollback_json)}"
|
||||
)
|
||||
|
||||
return _to_json_text(
|
||||
{
|
||||
"release": release_json,
|
||||
"sync": sync_json,
|
||||
"rollback": rollback_json,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error promoting skill candidate: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListSkillReleasesTool(FunctionTool):
|
||||
name: str = "astrbot_list_skill_releases"
|
||||
description: str = "List skill releases."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_key": {"type": "string"},
|
||||
"active_only": {"type": "boolean", "default": False},
|
||||
"stage": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 100},
|
||||
"offset": {"type": "integer", "default": 0},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
skill_key: str | None = None,
|
||||
active_only: bool = False,
|
||||
stage: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
result = await client.skills.list_releases(
|
||||
skill_key=skill_key,
|
||||
active_only=active_only,
|
||||
stage=stage,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error listing skill releases: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RollbackSkillReleaseTool(FunctionTool):
|
||||
name: str = "astrbot_rollback_skill_release"
|
||||
description: str = "Rollback one skill release."
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"release_id": {"type": "string"},
|
||||
},
|
||||
"required": ["release_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
release_id: str,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
result = await client.skills.rollback_release(release_id)
|
||||
return _to_json_text(result)
|
||||
except Exception as e:
|
||||
return f"Error rolling back skill release: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncSkillReleaseTool(FunctionTool):
|
||||
name: str = "astrbot_sync_skill_release"
|
||||
description: str = (
|
||||
"Sync stable Neo release payload to local SKILL.md and update mapping metadata."
|
||||
)
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"release_id": {"type": "string"},
|
||||
"skill_key": {"type": "string"},
|
||||
"require_stable": {"type": "boolean", "default": True},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
release_id: str | None = None,
|
||||
skill_key: str | None = None,
|
||||
require_stable: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, _sandbox = await _get_neo_context(context)
|
||||
sync_mgr = NeoSkillSyncManager()
|
||||
result = await sync_mgr.sync_release(
|
||||
client,
|
||||
release_id=release_id,
|
||||
skill_key=skill_key,
|
||||
require_stable=require_stable,
|
||||
)
|
||||
return _to_json_text(
|
||||
{
|
||||
"skill_key": result.skill_key,
|
||||
"local_skill_name": result.local_skill_name,
|
||||
"release_id": result.release_id,
|
||||
"candidate_id": result.candidate_id,
|
||||
"payload_ref": result.payload_ref,
|
||||
"map_path": result.map_path,
|
||||
"synced_at": result.synced_at,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return f"Error syncing skill release: {str(e)}"
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes
|
||||
from astrbot.core.skills.skill_manager import SkillManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_skills_path
|
||||
|
||||
_MAP_VERSION = 1
|
||||
_MAP_FILE_NAME = "neo_skill_map.json"
|
||||
_SKILL_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _to_jsonable(model_like: Any) -> dict[str, Any]:
|
||||
if isinstance(model_like, dict):
|
||||
return model_like
|
||||
if hasattr(model_like, "model_dump"):
|
||||
dumped = model_like.model_dump()
|
||||
if isinstance(dumped, dict):
|
||||
return dumped
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class NeoSkillSyncResult:
|
||||
skill_key: str
|
||||
local_skill_name: str
|
||||
release_id: str
|
||||
candidate_id: str
|
||||
payload_ref: str
|
||||
map_path: str
|
||||
synced_at: str
|
||||
|
||||
|
||||
class NeoSkillSyncManager:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
skills_root: str | None = None,
|
||||
map_path: str | None = None,
|
||||
) -> None:
|
||||
self.skills_root = skills_root or get_astrbot_skills_path()
|
||||
self.map_path = map_path or str(Path(self.skills_root) / _MAP_FILE_NAME)
|
||||
os.makedirs(self.skills_root, exist_ok=True)
|
||||
|
||||
def _load_map(self) -> dict[str, Any]:
|
||||
if not os.path.exists(self.map_path):
|
||||
return {"version": _MAP_VERSION, "items": {}}
|
||||
try:
|
||||
with open(self.map_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {"version": _MAP_VERSION, "items": {}}
|
||||
items = data.get("items", {})
|
||||
if not isinstance(items, dict):
|
||||
items = {}
|
||||
return {"version": int(data.get("version", _MAP_VERSION)), "items": items}
|
||||
except Exception:
|
||||
return {"version": _MAP_VERSION, "items": {}}
|
||||
|
||||
def _save_map(self, data: dict[str, Any]) -> None:
|
||||
os.makedirs(os.path.dirname(self.map_path), exist_ok=True)
|
||||
with open(self.map_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def normalize_skill_name(skill_key: str) -> str:
|
||||
normalized = _SKILL_NAME_RE.sub("-", skill_key.strip().lower())
|
||||
normalized = normalized.strip("._-")
|
||||
if not normalized:
|
||||
normalized = "skill"
|
||||
return f"neo_{normalized}"
|
||||
|
||||
def _resolve_local_skill_name(self, skill_key: str, mapping: dict[str, Any]) -> str:
|
||||
items = mapping.get("items", {})
|
||||
if not isinstance(items, dict):
|
||||
items = {}
|
||||
existing = items.get(skill_key)
|
||||
if isinstance(existing, dict):
|
||||
local_name = existing.get("local_skill_name")
|
||||
if isinstance(local_name, str) and local_name:
|
||||
return local_name
|
||||
|
||||
base = self.normalize_skill_name(skill_key)
|
||||
used_names = {
|
||||
str(v.get("local_skill_name"))
|
||||
for v in items.values()
|
||||
if isinstance(v, dict) and v.get("local_skill_name")
|
||||
}
|
||||
if base not in used_names:
|
||||
return base
|
||||
suffix = hashlib.sha1(skill_key.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{base}-{suffix}"
|
||||
|
||||
async def _find_release(self, client: Any, *, release_id: str) -> dict[str, Any]:
|
||||
offset = 0
|
||||
while True:
|
||||
page = await client.skills.list_releases(limit=100, offset=offset)
|
||||
page_json = _to_jsonable(page)
|
||||
items = page_json.get("items", [])
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == release_id:
|
||||
return item
|
||||
total = int(page_json.get("total", 0) or 0)
|
||||
offset += len(items)
|
||||
if offset >= total or not items:
|
||||
break
|
||||
raise ValueError(f"Release not found: {release_id}")
|
||||
|
||||
async def _find_active_stable_release(
|
||||
self,
|
||||
client: Any,
|
||||
*,
|
||||
skill_key: str,
|
||||
) -> dict[str, Any]:
|
||||
page = await client.skills.list_releases(
|
||||
skill_key=skill_key,
|
||||
active_only=True,
|
||||
stage="stable",
|
||||
limit=1,
|
||||
offset=0,
|
||||
)
|
||||
page_json = _to_jsonable(page)
|
||||
items = page_json.get("items", [])
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError(f"No active stable release found for skill_key: {skill_key}")
|
||||
if not isinstance(items[0], dict):
|
||||
raise ValueError("Unexpected release payload format.")
|
||||
return items[0]
|
||||
|
||||
async def sync_release(
|
||||
self,
|
||||
client: Any,
|
||||
*,
|
||||
release_id: str | None = None,
|
||||
skill_key: str | None = None,
|
||||
require_stable: bool = True,
|
||||
) -> NeoSkillSyncResult:
|
||||
if release_id:
|
||||
release = await self._find_release(client, release_id=release_id)
|
||||
elif skill_key:
|
||||
release = await self._find_active_stable_release(client, skill_key=skill_key)
|
||||
else:
|
||||
raise ValueError("release_id or skill_key is required for sync.")
|
||||
|
||||
release_id_val = str(release.get("id") or "")
|
||||
release_stage = str(release.get("stage") or "")
|
||||
skill_key_val = str(release.get("skill_key") or "")
|
||||
candidate_id = str(release.get("candidate_id") or "")
|
||||
|
||||
if not release_id_val or not skill_key_val or not candidate_id:
|
||||
raise ValueError("Release payload is incomplete.")
|
||||
if require_stable and release_stage != "stable":
|
||||
raise ValueError(
|
||||
f"Only stable releases can be synced to local SKILL.md (got: {release_stage})."
|
||||
)
|
||||
|
||||
candidate = await client.skills.get_candidate(candidate_id)
|
||||
candidate_json = _to_jsonable(candidate)
|
||||
payload_ref = candidate_json.get("payload_ref")
|
||||
if not isinstance(payload_ref, str) or not payload_ref:
|
||||
raise ValueError("Candidate payload_ref is missing.")
|
||||
|
||||
payload_resp = await client.skills.get_payload(payload_ref)
|
||||
payload_json = _to_jsonable(payload_resp)
|
||||
payload = payload_json.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Skill payload must be a JSON object.")
|
||||
|
||||
skill_markdown = payload.get("skill_markdown")
|
||||
if not isinstance(skill_markdown, str) or not skill_markdown.strip():
|
||||
raise ValueError(
|
||||
"payload.skill_markdown is required for stable sync to local skill."
|
||||
)
|
||||
|
||||
mapping = self._load_map()
|
||||
local_skill_name = self._resolve_local_skill_name(skill_key_val, mapping)
|
||||
skill_dir = Path(self.skills_root) / local_skill_name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
skill_md_path = skill_dir / "SKILL.md"
|
||||
skill_md_path.write_text(skill_markdown, encoding="utf-8")
|
||||
|
||||
items = mapping.setdefault("items", {})
|
||||
items[skill_key_val] = {
|
||||
"local_skill_name": local_skill_name,
|
||||
"latest_release_id": release_id_val,
|
||||
"latest_candidate_id": candidate_id,
|
||||
"latest_payload_ref": payload_ref,
|
||||
"updated_at": _now_iso(),
|
||||
}
|
||||
mapping["version"] = _MAP_VERSION
|
||||
self._save_map(mapping)
|
||||
|
||||
# Ensure local skill is visible to AstrBot skill manager.
|
||||
SkillManager().set_skill_active(local_skill_name, True)
|
||||
|
||||
# Best-effort synchronization to active sandboxes.
|
||||
await sync_skills_to_active_sandboxes()
|
||||
|
||||
return NeoSkillSyncResult(
|
||||
skill_key=skill_key_val,
|
||||
local_skill_name=local_skill_name,
|
||||
release_id=release_id_val,
|
||||
candidate_id=candidate_id,
|
||||
payload_ref=payload_ref,
|
||||
map_path=self.map_path,
|
||||
synced_at=_now_iso(),
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
|
||||
|
||||
|
||||
class _FakeSkills:
|
||||
async def list_releases(self, **kwargs):
|
||||
_ = kwargs
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": "sr-1",
|
||||
"skill_key": "etl/loader@v1",
|
||||
"candidate_id": "sc-1",
|
||||
"stage": "stable",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
|
||||
async def get_candidate(self, candidate_id: str):
|
||||
assert candidate_id == "sc-1"
|
||||
return {
|
||||
"id": "sc-1",
|
||||
"payload_ref": "blob:blob-1",
|
||||
}
|
||||
|
||||
async def get_payload(self, payload_ref: str):
|
||||
assert payload_ref == "blob:blob-1"
|
||||
return {
|
||||
"payload_ref": payload_ref,
|
||||
"kind": "astrbot_skill_v1",
|
||||
"payload": {
|
||||
"skill_markdown": "---\ndescription: test\n---\n# title\ncontent",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self):
|
||||
self.skills = _FakeSkills()
|
||||
|
||||
|
||||
def test_sync_release_writes_skill_and_map(monkeypatch, tmp_path: Path):
|
||||
calls = {"active": [], "sandbox_sync": 0}
|
||||
|
||||
def _fake_set_skill_active(self, name, active):
|
||||
calls["active"].append((name, active))
|
||||
|
||||
async def _fake_sync_sandboxes():
|
||||
calls["sandbox_sync"] += 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.neo_skill_sync.SkillManager.set_skill_active",
|
||||
_fake_set_skill_active,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.neo_skill_sync.sync_skills_to_active_sandboxes",
|
||||
_fake_sync_sandboxes,
|
||||
)
|
||||
|
||||
skills_root = tmp_path / "skills"
|
||||
map_path = skills_root / "neo_skill_map.json"
|
||||
mgr = NeoSkillSyncManager(skills_root=str(skills_root), map_path=str(map_path))
|
||||
|
||||
result = asyncio.run(
|
||||
mgr.sync_release(_FakeClient(), release_id="sr-1", require_stable=True)
|
||||
)
|
||||
|
||||
assert result.skill_key == "etl/loader@v1"
|
||||
assert result.release_id == "sr-1"
|
||||
assert result.local_skill_name.startswith("neo_")
|
||||
assert calls["active"] == [(result.local_skill_name, True)]
|
||||
assert calls["sandbox_sync"] == 1
|
||||
|
||||
skill_md = skills_root / result.local_skill_name / "SKILL.md"
|
||||
assert skill_md.exists()
|
||||
assert "description: test" in skill_md.read_text(encoding="utf-8")
|
||||
|
||||
assert map_path.exists()
|
||||
map_text = map_path.read_text(encoding="utf-8")
|
||||
assert "etl/loader@v1" in map_text
|
||||
assert result.local_skill_name in map_text
|
||||
|
||||
|
||||
def test_sync_release_rejects_non_stable(monkeypatch, tmp_path: Path):
|
||||
class _CanarySkills(_FakeSkills):
|
||||
async def list_releases(self, **kwargs):
|
||||
_ = kwargs
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": "sr-1",
|
||||
"skill_key": "etl",
|
||||
"candidate_id": "sc-1",
|
||||
"stage": "canary",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
|
||||
class _CanaryClient:
|
||||
def __init__(self):
|
||||
self.skills = _CanarySkills()
|
||||
|
||||
async def _fake_sync_sandboxes():
|
||||
return
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.neo_skill_sync.sync_skills_to_active_sandboxes",
|
||||
_fake_sync_sandboxes,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.skills.neo_skill_sync.SkillManager.set_skill_active",
|
||||
lambda self, name, active: None,
|
||||
)
|
||||
|
||||
mgr = NeoSkillSyncManager(
|
||||
skills_root=str(tmp_path / "skills"),
|
||||
map_path=str(tmp_path / "skills" / "neo_skill_map.json"),
|
||||
)
|
||||
with pytest.raises(ValueError, match="Only stable releases"):
|
||||
asyncio.run(
|
||||
mgr.sync_release(_CanaryClient(), release_id="sr-1", require_stable=True)
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.computer.tools.neo_skills import PromoteSkillCandidateTool
|
||||
|
||||
|
||||
class _FakeSkills:
|
||||
def __init__(self):
|
||||
self.rollback_called_with = None
|
||||
|
||||
async def promote_candidate(self, candidate_id: str, stage: str = "canary"):
|
||||
assert candidate_id == "cand-1"
|
||||
assert stage == "stable"
|
||||
return {
|
||||
"id": "sr-1",
|
||||
"skill_key": "k1",
|
||||
"candidate_id": candidate_id,
|
||||
"stage": stage,
|
||||
}
|
||||
|
||||
async def rollback_release(self, release_id: str):
|
||||
self.rollback_called_with = release_id
|
||||
return {"id": "rb-1", "rollback_of": release_id}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self):
|
||||
self.skills = _FakeSkills()
|
||||
|
||||
|
||||
class _FakeBooter:
|
||||
def __init__(self):
|
||||
self.bay_client = _FakeClient()
|
||||
self.sandbox = object()
|
||||
|
||||
|
||||
def test_promote_stable_sync_failure_auto_rolls_back(monkeypatch):
|
||||
async def _fake_get_booter(_ctx, _session_id):
|
||||
return _FakeBooter()
|
||||
|
||||
async def _fake_sync_release(self, client, **kwargs):
|
||||
_ = self, client, kwargs
|
||||
raise ValueError("sync failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.computer.tools.neo_skills.get_booter",
|
||||
_fake_get_booter,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"astrbot.core.computer.tools.neo_skills.NeoSkillSyncManager.sync_release",
|
||||
_fake_sync_release,
|
||||
)
|
||||
|
||||
event = SimpleNamespace(role="admin", unified_msg_origin="session-1")
|
||||
astr_ctx = SimpleNamespace(context=SimpleNamespace(), event=event)
|
||||
run_ctx = ContextWrapper(context=astr_ctx)
|
||||
|
||||
tool = PromoteSkillCandidateTool()
|
||||
result = asyncio.run(
|
||||
tool.call(
|
||||
run_ctx,
|
||||
candidate_id="cand-1",
|
||||
stage="stable",
|
||||
sync_to_local=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "auto rollback succeeded" in result
|
||||
assert "sync failed" in result
|
||||
Reference in New Issue
Block a user