Skip to content

Commit cd382f2

Browse files
committed
add the function of script
1 parent 88b0e06 commit cd382f2

5 files changed

Lines changed: 267 additions & 5 deletions

File tree

mcp_server.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,46 @@ async def list_tools() -> list[Tool]:
382382
"required": ["session_id"]
383383
}
384384
),
385+
Tool(
386+
name="script_binding_set",
387+
description="Bind an external job_id (binding_key) to a script task_id, with optional default vars and description.",
388+
inputSchema={
389+
"type": "object",
390+
"properties": {
391+
"binding_key": {"type": "string", "description": "External job identifier (e.g. OpenClaw job_id)"},
392+
"script_task_id": {"type": "string", "description": "ZeroToken script task_id"},
393+
"description": {"type": "string", "description": "Optional human-readable description"},
394+
"default_vars": {"type": "object", "description": "Optional default vars for this binding"}
395+
},
396+
"required": ["binding_key", "script_task_id"]
397+
}
398+
),
399+
Tool(
400+
name="script_binding_get",
401+
description="Get a script binding by binding_key (job_id).",
402+
inputSchema={
403+
"type": "object",
404+
"properties": {"binding_key": {"type": "string"}},
405+
"required": ["binding_key"]
406+
}
407+
),
408+
Tool(
409+
name="script_binding_list",
410+
description="List script bindings.",
411+
inputSchema={
412+
"type": "object",
413+
"properties": {"limit": {"type": "integer", "default": 100}}
414+
}
415+
),
416+
Tool(
417+
name="script_binding_delete",
418+
description="Delete a script binding by binding_key.",
419+
inputSchema={
420+
"type": "object",
421+
"properties": {"binding_key": {"type": "string"}},
422+
"required": ["binding_key"]
423+
}
424+
),
385425
Tool(
386426
name="browser_init",
387427
description="Initialize the browser (call once before using other browser tools). Use stealth=True to reduce automation detection (launch args + fingerprint masking).",
@@ -803,6 +843,40 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
803843
steps = get_storage().session_get(session_id)
804844
return [TextContent(type="text", text=json.dumps({"success": True, "steps": steps}, indent=2, ensure_ascii=False))]
805845

846+
elif name == "script_binding_set":
847+
binding_key = arguments.get("binding_key")
848+
script_task_id = arguments.get("script_task_id")
849+
if not binding_key or not script_task_id:
850+
return [TextContent(type="text", text=_error_response("binding_key and script_task_id are required", code="INVALID_PARAMS", retryable=False))]
851+
get_storage().script_binding_set(
852+
binding_key,
853+
script_task_id=script_task_id,
854+
description=arguments.get("description", "") or "",
855+
default_vars=arguments.get("default_vars") or {},
856+
)
857+
return [TextContent(type="text", text=json.dumps({"success": True, "binding_key": binding_key, "script_task_id": script_task_id}, indent=2, ensure_ascii=False))]
858+
859+
elif name == "script_binding_get":
860+
binding_key = arguments.get("binding_key")
861+
if not binding_key:
862+
return [TextContent(type="text", text=_error_response("binding_key is required", code="INVALID_PARAMS", retryable=False))]
863+
binding = get_storage().script_binding_get(binding_key)
864+
if binding is None:
865+
return [TextContent(type="text", text=_error_response(f"No binding for key: {binding_key}", code="SCRIPT_BINDING_NOT_FOUND", retryable=False))]
866+
return [TextContent(type="text", text=json.dumps({"success": True, "binding": binding}, indent=2, ensure_ascii=False))]
867+
868+
elif name == "script_binding_list":
869+
limit = arguments.get("limit", 100)
870+
items = get_storage().script_binding_list(limit=limit)
871+
return [TextContent(type="text", text=json.dumps({"bindings": items}, indent=2, ensure_ascii=False))]
872+
873+
elif name == "script_binding_delete":
874+
binding_key = arguments.get("binding_key")
875+
if not binding_key:
876+
return [TextContent(type="text", text=_error_response("binding_key is required", code="INVALID_PARAMS", retryable=False))]
877+
ok = get_storage().script_binding_delete(binding_key)
878+
return [TextContent(type="text", text=json.dumps({"success": True, "deleted": ok}, indent=2, ensure_ascii=False))]
879+
806880
else:
807881
return [TextContent(
808882
type="text",

skills/zerotoken-openclaw/SKILL.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,26 @@ Agent 应:
8585

8686
**不主动生成**:未提复用、未提定时/重复时,只做轨迹录制与保存。若用户后续要脚本再生成。
8787

88+
## 定时任务如何找到对应脚本(基于 job_id 绑定)
89+
90+
**OpenClaw 以定时任务触发本 Skill** 时,事件参数中会携带该任务的 `job_id`。ZeroToken 使用 `job_id` 作为绑定键(`binding_key`),并在 MCP 数据库的 `script_bindings` 表中维护「job_id ↔ 脚本」关系。
91+
92+
Agent 必须遵守以下约定:
93+
94+
1. **始终使用 `job_id` 作为 binding_key 查询绑定**
95+
- 调用 `script_binding_get(binding_key=job_id)`
96+
2. 若返回 binding:
97+
- 读取 `binding.script_task_id``binding.default_vars`
98+
- 合并本次任务特有的参数(如用户给定的视频 ID、时间范围)到 vars;
99+
- 调用 `run_script(binding.script_task_id, vars=merged_vars)` 执行脚本。
100+
3.`script_binding_get(job_id)` 返回「未找到」:
101+
- 提示用户「当前 job_id 尚未绑定 ZeroToken 脚本」;
102+
- 不要随意尝试其他脚本或自动新建脚本。
103+
4. 对于没有 `job_id` 或未标记为定时任务的场景:
104+
- 视为「一次性任务」,只使用 `browser_*` + `trajectory_*` 完成当前需求,不主动查找/执行脚本。
105+
106+
开发者应在 ZeroToken 侧或 OpenClaw 的集成层中,使用 `script_binding_set(binding_key=job_id, script_task_id=..., default_vars?, description?)` 预先将定时任务 job_id 与脚本 `task_id` 明确绑定。本 Skill 仅通过 `job_id` 查询绑定,**不对映射关系做额外推断**
107+
88108
## 脚本格式与执行方式
89109

90110
### 格式(存于 MCP 数据库)
@@ -111,16 +131,23 @@ Agent 应:
111131
- 可选 `fuzzy_point`:记录该步「需要 AI/人介入」的语义信息(`reason``hint`),**本身不会让 ScriptEngine 自动暂停**;只有当为该步配置了匹配的 DFU / 执行点时,`run_script` 执行到该步才会返回 `status="paused"`
112132
- 可选参数化:`params` 中可用 `{{varname}}`,执行前由 Agent 或配置替换(如环境变量、用户输入),或在 ExecutionPoint/DFU 暂停时由上层生成 `resolution.vars` 合并进运行时变量环境。
113133

114-
### 执行脚本(由 ScriptEngine 自动化顺序执行)
134+
### 执行脚本(仅在定时 / 重复任务场景)
135+
136+
**只有在以下两种情况下,才去查找并执行脚本:**
115137

116-
当用户或 cron 消息为「执行 ZeroToken 脚本 <task_id>」或「跑一下 <task_id> 的脚本」时:
138+
- 上下文/cron 明确表明是「定时 / 周期性 / 重复执行」的任务(如每日评论、每小时抓取报表)。
139+
- 用户明确说「执行 ZeroToken 脚本 <task_id>」「跑一下 <task_id> 的脚本」等。
117140

118-
1. 调用 `script_load(task_id)` 从 MCP 数据库读取脚本;若无则提示先根据轨迹生成并 `script_save`
141+
在这些情况下:
142+
143+
1. 调用 `script_load(task_id)` 从 MCP 数据库读取脚本;若无则提示先根据对应轨迹生成并 `script_save`(否则不要擅自造脚本)。
119144
2. 调用 `run_script(task_id, vars?)`**MCP 内的 ScriptEngine 自动按 `steps` 顺序执行脚本**,无需 LLM,执行过程写入 session;返回形如 `{"success": ..., "status": "success|paused|failed", "session_id": ...}`
120145
3. 若返回 `status="paused"`(例如命中 DFU / 执行点 / 失败重试上限):
121146
- 上层 Agent 阅读 `pause_event`(包含 step_index、dfu_id、提示文案与需要生成的 vars),做一次决策或生成 vars;
122147
- 再调用 `run_script(session_id=..., resolution={...})` 恢复执行,由 ScriptEngine 继续顺序执行后续 steps。
123148

149+
非定时/一次性任务:**优先只用 browser_* + trajectory_* 录制与完成当前任务,不主动查找/执行脚本。**
150+
124151
脚本是「数据驱动的 MCP 调用序列」,**存于 MCP 数据库,由 ScriptEngine 自动化回放**,Token 消耗低且可通过 session 追踪每次执行。
125152

126153
### 模糊点 / DFU 执行约定

tests/test_script_bindings.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Tests for script bindings (job_id -> script_task_id) in SQLiteStorage."""
2+
3+
import os
4+
import tempfile
5+
6+
import pytest
7+
8+
from zerotoken.storage_sqlite import SQLiteStorage
9+
10+
11+
@pytest.fixture
12+
def db_path():
13+
fd, path = tempfile.mkstemp(suffix=".db")
14+
os.close(fd)
15+
yield path
16+
try:
17+
os.unlink(path)
18+
except OSError:
19+
pass
20+
21+
22+
@pytest.fixture
23+
def storage(db_path):
24+
return SQLiteStorage(db_path)
25+
26+
27+
def test_script_binding_set_get_list_delete(storage: SQLiteStorage):
28+
key = "job_daily_comment"
29+
storage.script_binding_set(
30+
key,
31+
script_task_id="task_script_1",
32+
description="每日评论脚本",
33+
default_vars={"channel": "bilibili", "video_id": "abc"},
34+
)
35+
36+
binding = storage.script_binding_get(key)
37+
assert binding is not None
38+
assert binding["binding_key"] == key
39+
assert binding["script_task_id"] == "task_script_1"
40+
assert binding["description"] == "每日评论脚本"
41+
assert binding["default_vars"]["channel"] == "bilibili"
42+
43+
items = storage.script_binding_list()
44+
assert any(it["binding_key"] == key for it in items)
45+
46+
assert storage.script_binding_delete(key) is True
47+
assert storage.script_binding_get(key) is None
48+

zerotoken/storage.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,34 @@ def runtime_update(
177177
) -> None:
178178
"""Update runtime state fields for a session."""
179179
...
180+
181+
182+
class ScriptBindingStore(ABC):
183+
"""Abstract store for binding external job_ids to script task_ids."""
184+
185+
@abstractmethod
186+
def script_binding_set(
187+
self,
188+
binding_key: str,
189+
*,
190+
script_task_id: str,
191+
description: str = "",
192+
default_vars: Optional[Dict[str, Any]] = None,
193+
) -> None:
194+
"""Create or update a binding from binding_key (e.g. OpenClaw job_id) to script_task_id."""
195+
...
196+
197+
@abstractmethod
198+
def script_binding_get(self, binding_key: str) -> Optional[Dict[str, Any]]:
199+
"""Get binding by binding_key. Returns None if not found."""
200+
...
201+
202+
@abstractmethod
203+
def script_binding_list(self, limit: int = 100) -> List[Dict[str, Any]]:
204+
"""List bindings (binding_key, script_task_id, description)."""
205+
...
206+
207+
@abstractmethod
208+
def script_binding_delete(self, binding_key: str) -> bool:
209+
"""Delete binding. Returns True if deleted."""
210+
...

zerotoken/storage_sqlite.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pathlib import Path
99
from typing import Any, Dict, List, Optional
1010

11-
from .storage import ScriptStore, TrajectoryStore, SessionStore, DFUStore, SessionRuntimeStore
11+
from .storage import ScriptStore, TrajectoryStore, SessionStore, DFUStore, SessionRuntimeStore, ScriptBindingStore
1212

1313
_RUNTIME_UNSET = object()
1414

@@ -23,7 +23,7 @@ def _json_deserializer(s: Optional[str]) -> Any:
2323
return json.loads(s)
2424

2525

26-
class SQLiteStorage(ScriptStore, TrajectoryStore, SessionStore, DFUStore, SessionRuntimeStore):
26+
class SQLiteStorage(ScriptStore, TrajectoryStore, SessionStore, DFUStore, SessionRuntimeStore, ScriptBindingStore):
2727
"""Single SQLite-backed storage for scripts, trajectories, and sessions."""
2828

2929
def __init__(self, db_path: str = "zerotoken.db"):
@@ -110,6 +110,16 @@ def _ensure_tables(self) -> None:
110110
updated_at TEXT NOT NULL
111111
)
112112
""")
113+
cur.execute("""
114+
CREATE TABLE IF NOT EXISTS script_bindings (
115+
binding_key TEXT PRIMARY KEY,
116+
script_task_id TEXT NOT NULL,
117+
description TEXT,
118+
default_vars_json TEXT,
119+
created_at TEXT NOT NULL,
120+
updated_at TEXT NOT NULL
121+
)
122+
""")
113123
self.conn.commit()
114124

115125
# --- ScriptStore ---
@@ -147,6 +157,78 @@ def script_save(
147157
)
148158
self.conn.commit()
149159

160+
# --- ScriptBindingStore ---
161+
def script_binding_set(
162+
self,
163+
binding_key: str,
164+
*,
165+
script_task_id: str,
166+
description: str = "",
167+
default_vars: Optional[Dict[str, Any]] = None,
168+
) -> None:
169+
now = datetime.utcnow().isoformat() + "Z"
170+
cur = self.conn.cursor()
171+
cur.execute(
172+
"""
173+
INSERT INTO script_bindings (binding_key, script_task_id, description, default_vars_json, created_at, updated_at)
174+
VALUES (?, ?, ?, ?, ?, ?)
175+
ON CONFLICT(binding_key) DO UPDATE SET
176+
script_task_id = excluded.script_task_id,
177+
description = excluded.description,
178+
default_vars_json = excluded.default_vars_json,
179+
updated_at = excluded.updated_at
180+
""",
181+
(
182+
binding_key,
183+
script_task_id,
184+
description,
185+
_json_serializer(default_vars or {}),
186+
now,
187+
now,
188+
),
189+
)
190+
self.conn.commit()
191+
192+
def script_binding_get(self, binding_key: str) -> Optional[Dict[str, Any]]:
193+
cur = self.conn.cursor()
194+
cur.execute(
195+
"SELECT binding_key, script_task_id, description, default_vars_json, created_at, updated_at FROM script_bindings WHERE binding_key = ?",
196+
(binding_key,),
197+
)
198+
row = cur.fetchone()
199+
if row is None:
200+
return None
201+
return {
202+
"binding_key": row["binding_key"],
203+
"script_task_id": row["script_task_id"],
204+
"description": row["description"] or "",
205+
"default_vars": _json_deserializer(row["default_vars_json"]) or {},
206+
"created_at": row["created_at"],
207+
"updated_at": row["updated_at"],
208+
}
209+
210+
def script_binding_list(self, limit: int = 100) -> List[Dict[str, Any]]:
211+
cur = self.conn.cursor()
212+
cur.execute(
213+
"SELECT binding_key, script_task_id, description, updated_at FROM script_bindings ORDER BY updated_at DESC LIMIT ?",
214+
(limit,),
215+
)
216+
return [
217+
{
218+
"binding_key": r["binding_key"],
219+
"script_task_id": r["script_task_id"],
220+
"description": r["description"] or "",
221+
"updated_at": r["updated_at"],
222+
}
223+
for r in cur.fetchall()
224+
]
225+
226+
def script_binding_delete(self, binding_key: str) -> bool:
227+
cur = self.conn.cursor()
228+
cur.execute("DELETE FROM script_bindings WHERE binding_key = ?", (binding_key,))
229+
self.conn.commit()
230+
return cur.rowcount > 0
231+
150232
def script_load(self, task_id: str) -> Optional[Dict[str, Any]]:
151233
cur = self.conn.cursor()
152234
cur.execute(

0 commit comments

Comments
 (0)