feat: add skills and upgrade feishu/dingtalk channel

This commit is contained in:
zhayujie
2026-02-02 00:42:39 +08:00
parent 77c2bfcc1e
commit a8d5309c90
32 changed files with 2931 additions and 200 deletions

View File

@@ -42,24 +42,26 @@ Agent: [调用 scheduler 工具]
**示例对话:**
```
用户: 每天早上8点帮我搜索一下当前新闻
用户: 每天早上8点帮我读取一下今日日程
Agent: [调用 scheduler 工具]
action: create
name: 每日新闻
name: 每日日程
tool_call:
tool_name: bocha_search
tool_name: read
tool_params:
query: 今日新闻
result_prefix: 📰 今日新闻播报
file_path: ~/cow/schedule.txt
result_prefix: 📅 今日日程
schedule_type: cron
schedule_value: 0 8 * * *
```
**工具调用参数说明:**
- `tool_name`: 要调用的工具名称(如 `bocha_search``web_fetch`
- `tool_name`: 要调用的工具名称(如 `bash``read``write` 等内置工具
- `tool_params`: 工具的参数(字典格式)
- `result_prefix`: 可选,在结果前添加的前缀文本
**注意:** 如果要使用 skills如 bocha-search需要通过 `bash` 工具调用 skill 脚本
### 2. 支持的调度类型
#### Cron 表达式 (`cron`)
@@ -167,7 +169,7 @@ Agent: [调用 scheduler 工具]
```json
{
"id": "def456",
"name": "每日新闻",
"name": "每日日程",
"enabled": true,
"created_at": "2024-01-01T10:00:00",
"updated_at": "2024-01-01T10:00:00",
@@ -177,11 +179,11 @@ Agent: [调用 scheduler 工具]
},
"action": {
"type": "tool_call",
"tool_name": "bocha_search",
"tool_name": "read",
"tool_params": {
"query": "今日新闻"
"file_path": "~/cow/schedule.txt"
},
"result_prefix": "📰 今日新闻播报",
"result_prefix": "📅 今日日程",
"receiver": "wxid_xxx",
"receiver_name": "张三",
"is_group": false,
@@ -234,30 +236,29 @@ Agent: [创建 cron: 0 18 * * 1-5]
Agent: [创建 interval: 3600]
```
### 4. 每日新闻推送(动态工具调用)
### 4. 每日日程推送(动态工具调用)
```
用户: 每天早上8点帮我搜索一下当前新闻
用户: 每天早上8点帮我读取今日日程
Agent: ✅ 定时任务创建成功
任务ID: news001
任务ID: schedule001
调度: 每天 8:00
工具: bocha_search(query='今日新闻')
前缀: 📰 今日新闻播报
工具: read(file_path='~/cow/schedule.txt')
前缀: 📅 今日日程
```
### 5. 定时天气查询(动态工具调用)
### 5. 定时文件备份(动态工具调用)
```
用户: 每天早上7点查询今天的天气
Agent: [创建 cron: 0 7 * * *]
工具: bocha_search(query='今日天气')
前缀: 🌤️ 今日天气预报
用户: 每天晚上11点备份工作文件
Agent: [创建 cron: 0 23 * * *]
工具: bash(command='cp ~/cow/work.txt ~/cow/backup/work_$(date +%Y%m%d).txt')
前缀: ✅ 文件已备份
```
### 6. 周报提醒(动态工具调用
### 6. 周报提醒(静态消息
```
用户: 每周五下午5点搜索本周热点
用户: 每周五下午5点提醒我写周报
Agent: [创建 cron: 0 17 * * 5]
工具: bocha_search(query='本周热点新闻')
前缀: 📊 本周热点回顾
消息: 📊 该写周报了!
```
### 4. 特定日期提醒

View File

@@ -45,10 +45,17 @@ def init_scheduler(agent_bridge) -> bool:
action = task.get("action", {})
action_type = action.get("type")
if action_type == "send_message":
if action_type == "agent_task":
_execute_agent_task(task, agent_bridge)
elif action_type == "send_message":
# Legacy support for old tasks
_execute_send_message(task, agent_bridge)
elif action_type == "tool_call":
# Legacy support for old tasks
_execute_tool_call(task, agent_bridge)
elif action_type == "skill_call":
# Legacy support for old tasks
_execute_skill_call(task, agent_bridge)
else:
logger.warning(f"[Scheduler] Unknown action type: {action_type}")
except Exception as e:
@@ -76,6 +83,100 @@ def get_scheduler_service():
return _scheduler_service
def _execute_agent_task(task: dict, agent_bridge):
"""
Execute an agent_task action - let Agent handle the task
Args:
task: Task dictionary
agent_bridge: AgentBridge instance
"""
try:
action = task.get("action", {})
task_description = action.get("task_description")
receiver = action.get("receiver")
is_group = action.get("is_group", False)
channel_type = action.get("channel_type", "unknown")
if not task_description:
logger.error(f"[Scheduler] Task {task['id']}: No task_description specified")
return
if not receiver:
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
return
# Check for unsupported channels
if channel_type == "dingtalk":
logger.warning(f"[Scheduler] Task {task['id']}: DingTalk channel does not support scheduled messages (Stream mode limitation). Task will execute but message cannot be sent.")
logger.info(f"[Scheduler] Task {task['id']}: Executing agent task '{task_description}'")
# Create context for Agent
context = Context(ContextType.TEXT, task_description)
context["receiver"] = receiver
context["isgroup"] = is_group
context["session_id"] = receiver
# Channel-specific setup
if channel_type == "web":
import uuid
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
context["request_id"] = request_id
elif channel_type == "feishu":
context["receive_id_type"] = "chat_id" if is_group else "open_id"
context["msg"] = None
elif channel_type == "dingtalk":
# DingTalk requires msg object, set to None for scheduled tasks
context["msg"] = None
# 如果是单聊,需要传递 sender_staff_id
if not is_group:
sender_staff_id = action.get("dingtalk_sender_staff_id")
if sender_staff_id:
context["dingtalk_sender_staff_id"] = sender_staff_id
# Use Agent to execute the task
# Mark this as a scheduled task execution to prevent recursive task creation
context["is_scheduled_task"] = True
try:
reply = agent_bridge.agent_reply(task_description, context=context, on_event=None, clear_history=True)
if reply and reply.content:
# Send the reply via channel
from channel.channel_factory import create_channel
try:
channel = create_channel(channel_type)
if channel:
# For web channel, register request_id
if channel_type == "web" and hasattr(channel, 'request_to_session'):
request_id = context.get("request_id")
if request_id:
channel.request_to_session[request_id] = receiver
logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}")
# Send the reply
channel.send(reply, context)
logger.info(f"[Scheduler] Task {task['id']} executed successfully, result sent to {receiver}")
else:
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
except Exception as e:
logger.error(f"[Scheduler] Failed to send result: {e}")
else:
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
except Exception as e:
logger.error(f"[Scheduler] Failed to execute task via Agent: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
except Exception as e:
logger.error(f"[Scheduler] Error in _execute_agent_task: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
def _execute_send_message(task: dict, agent_bridge):
"""
Execute a send_message action
@@ -116,6 +217,17 @@ def _execute_send_message(task: dict, agent_bridge):
# Feishu channel will detect this and send as new message instead of reply
context["msg"] = None
logger.debug(f"[Scheduler] Feishu: receive_id_type={context['receive_id_type']}, is_group={is_group}, receiver={receiver}")
elif channel_type == "dingtalk":
# DingTalk channel setup
context["msg"] = None
# 如果是单聊,需要传递 sender_staff_id
if not is_group:
sender_staff_id = action.get("dingtalk_sender_staff_id")
if sender_staff_id:
context["dingtalk_sender_staff_id"] = sender_staff_id
logger.debug(f"[Scheduler] DingTalk single chat: sender_staff_id={sender_staff_id}")
else:
logger.warning(f"[Scheduler] Task {task['id']}: DingTalk single chat message missing sender_staff_id")
# Create reply
reply = Reply(ReplyType.TEXT, content)
@@ -156,8 +268,9 @@ def _execute_tool_call(task: dict, agent_bridge):
"""
try:
action = task.get("action", {})
tool_name = action.get("tool_name")
tool_params = action.get("tool_params", {})
# Support both old and new field names
tool_name = action.get("call_name") or action.get("tool_name")
tool_params = action.get("call_params") or action.get("tool_params", {})
result_prefix = action.get("result_prefix", "")
receiver = action.get("receiver")
is_group = action.get("is_group", False)
@@ -237,6 +350,82 @@ def _execute_tool_call(task: dict, agent_bridge):
logger.error(f"[Scheduler] Error in _execute_tool_call: {e}")
def _execute_skill_call(task: dict, agent_bridge):
"""
Execute a skill_call action by asking Agent to run the skill
Args:
task: Task dictionary
agent_bridge: AgentBridge instance
"""
try:
action = task.get("action", {})
# Support both old and new field names
skill_name = action.get("call_name") or action.get("skill_name")
skill_params = action.get("call_params") or action.get("skill_params", {})
result_prefix = action.get("result_prefix", "")
receiver = action.get("receiver")
is_group = action.get("isgroup", False)
channel_type = action.get("channel_type", "unknown")
if not skill_name:
logger.error(f"[Scheduler] Task {task['id']}: No skill_name specified")
return
if not receiver:
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
return
logger.info(f"[Scheduler] Task {task['id']}: Executing skill '{skill_name}' with params {skill_params}")
# Build a natural language query for the Agent to execute the skill
# Format: "Use skill-name to do something with params"
param_str = ", ".join([f"{k}={v}" for k, v in skill_params.items()])
query = f"Use {skill_name} skill"
if param_str:
query += f" with {param_str}"
# Create context for Agent
context = Context(ContextType.TEXT, query)
context["receiver"] = receiver
context["isgroup"] = is_group
context["session_id"] = receiver
# Channel-specific setup
if channel_type == "web":
import uuid
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
context["request_id"] = request_id
elif channel_type == "feishu":
context["receive_id_type"] = "chat_id" if is_group else "open_id"
context["msg"] = None
# Use Agent to execute the skill
try:
reply = agent_bridge.agent_reply(query, context=context, on_event=None, clear_history=True)
if reply and reply.content:
content = reply.content
# Add prefix if specified
if result_prefix:
content = f"{result_prefix}\n\n{content}"
logger.info(f"[Scheduler] Task {task['id']} executed: skill result sent to {receiver}")
else:
logger.error(f"[Scheduler] Task {task['id']}: No result from skill execution")
except Exception as e:
logger.error(f"[Scheduler] Failed to execute skill via Agent: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
except Exception as e:
logger.error(f"[Scheduler] Error in _execute_skill_call: {e}")
import traceback
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
def attach_scheduler_to_tool(tool, context: Context = None):
"""
Attach scheduler components to a SchedulerTool instance

View File

@@ -118,6 +118,34 @@ class SchedulerService:
try:
next_run = datetime.fromisoformat(next_run_str)
# Check if task is overdue (e.g., service restart)
if next_run < now:
time_diff = (now - next_run).total_seconds()
# If overdue by more than 5 minutes, skip this run and schedule next
if time_diff > 300: # 5 minutes
logger.warning(f"[Scheduler] Task {task['id']} is overdue by {int(time_diff)}s, skipping and scheduling next run")
# For one-time tasks, disable them
schedule = task.get("schedule", {})
if schedule.get("type") == "once":
self.task_store.update_task(task['id'], {
"enabled": False,
"last_run_at": now.isoformat()
})
logger.info(f"[Scheduler] One-time task {task['id']} expired, disabled")
return False
# For recurring tasks, calculate next run from now
next_next_run = self._calculate_next_run(task, now)
if next_next_run:
self.task_store.update_task(task['id'], {
"next_run_at": next_next_run.isoformat()
})
logger.info(f"[Scheduler] Rescheduled task {task['id']} to {next_next_run}")
return False
return now >= next_run
except:
return False

View File

@@ -20,23 +20,16 @@ class SchedulerTool(BaseTool):
name: str = "scheduler"
description: str = (
"创建、查询和管理定时任务。支持两种任务类型:\n"
"1. 静态消息任务:定时发送预定义的消息\n"
"2. 动态工具任务:定时执行工具调用并发送结果(如搜索新闻、查询天气等)\n\n"
"创建、查询和管理定时任务。支持固定消息和AI任务两种类型。\n\n"
"使用方法:\n"
"- 创建静态消息任务action='create', name='任务名', message='消息内容', schedule_type='interval'/'cron'/'once', schedule_value='间隔秒数/cron表达式/时间'\n"
"- 创建动态工具任务action='create', name='任务名', tool_call={'tool_name': '工具名', 'tool_params': {...}, 'result_prefix': '前缀'}, schedule_type='interval'/'cron'/'once', schedule_value=''\n"
"- 查询列表action='list'\n"
"- 查看详情action='get', task_id='任务ID'\n"
"- 删除任务action='delete', task_id='任务ID'\n"
"- 启用任务action='enable', task_id='任务ID'\n"
"- 禁用任务action='disable', task_id='任务ID'\n\n"
"调度类型说明:\n"
"- interval: 固定间隔秒数如3600表示每小时\n"
"- cron: cron表达式'0 9 * * *'表示每天9点'*/10 * * * *'表示每10分钟\n"
"- once: 一次性任务ISO时间格式'2024-12-25T09:00:00'\n\n"
"示例每天早上8点搜索新闻\n"
"action='create', name='每日新闻', tool_call={'tool_name': 'bocha_search', 'tool_params': {'query': '今日新闻'}, 'result_prefix': '📰 今日新闻播报'}, schedule_type='cron', schedule_value='0 8 * * *'"
"- 创建action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n"
"- 查询action='list' / action='get', task_id='任务ID'\n"
"- 管理action='delete/enable/disable', task_id='任务ID'\n\n"
"调度类型:\n"
"- once: 一次性任务,支持相对时间(+5s,+10m,+1h,+1d)或ISO时间\n"
"- interval: 固定间隔(秒)如3600表示每小时\n"
"- cron: cron表达式'0 8 * * *'表示每天8点\n\n"
"注意:'X秒后'用once+相对时间,'每X秒'用interval"
)
params: dict = {
"type": "object",
@@ -56,26 +49,11 @@ class SchedulerTool(BaseTool):
},
"message": {
"type": "string",
"description": "要发送的静态消息内容 (用于 create 操作与tool_call二选一)"
"description": "固定消息内容 (与ai_task二选一)"
},
"tool_call": {
"type": "object",
"description": "要执行的工具调用 (用于 create 操作与message二选一)",
"properties": {
"tool_name": {
"type": "string",
"description": "工具名称,如 'bocha_search'"
},
"tool_params": {
"type": "object",
"description": "工具参数"
},
"result_prefix": {
"type": "string",
"description": "结果前缀,如 '今日新闻:'"
}
},
"required": ["tool_name"]
"ai_task": {
"type": "string",
"description": "AI任务描述 (与message二选一),如'搜索今日新闻''查询天气'"
},
"schedule_type": {
"type": "string",
@@ -84,12 +62,7 @@ class SchedulerTool(BaseTool):
},
"schedule_value": {
"type": "string",
"description": (
"调度值 (用于 create 操作):\n"
"- cron类型: cron表达式'0 9 * * *' (每天9点)'*/10 * * * *' (每10分钟)\n"
"- interval类型: 间隔秒数,如 '3600' (每小时)'10' (每10秒)\n"
"- once类型: ISO时间'2024-12-25T09:00:00'"
)
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
}
},
"required": ["action"]
@@ -151,17 +124,20 @@ class SchedulerTool(BaseTool):
"""Create a new scheduled task"""
name = kwargs.get("name")
message = kwargs.get("message")
tool_call = kwargs.get("tool_call")
ai_task = kwargs.get("ai_task")
schedule_type = kwargs.get("schedule_type")
schedule_value = kwargs.get("schedule_value")
# Validate required fields
if not name:
return "错误: 缺少任务名称 (name)"
if not message and not tool_call:
return "错误: 必须提供 message 或 tool_call 之一"
if message and tool_call:
return "错误: message 和 tool_call 不能同时提供,请选择其"
# Check that exactly one of message/ai_task is provided
if not message and not ai_task:
return "错误: 必须提供 message(固定消息)或 ai_taskAI任务"
if message and ai_task:
return "错误: message 和 ai_task 只能提供其中一个"
if not schedule_type:
return "错误: 缺少调度类型 (schedule_type)"
if not schedule_value:
@@ -181,7 +157,7 @@ class SchedulerTool(BaseTool):
# Create task
task_id = str(uuid.uuid4())[:8]
# Build action based on message or tool_call
# Build action based on message or ai_task
if message:
action = {
"type": "send_message",
@@ -191,19 +167,22 @@ class SchedulerTool(BaseTool):
"is_group": context.get("isgroup", False),
"channel_type": self.config.get("channel_type", "unknown")
}
else: # tool_call
else: # ai_task
action = {
"type": "tool_call",
"tool_name": tool_call.get("tool_name"),
"tool_params": tool_call.get("tool_params", {}),
"result_prefix": tool_call.get("result_prefix", ""),
"type": "agent_task",
"task_description": ai_task,
"receiver": context.get("receiver"),
"receiver_name": self._get_receiver_name(context),
"is_group": context.get("isgroup", False),
"channel_type": self.config.get("channel_type", "unknown")
}
task = {
# 针对钉钉单聊,额外存储 sender_staff_id
msg = context.kwargs.get("msg")
if msg and hasattr(msg, 'sender_staff_id') and not context.get("isgroup", False):
action["dingtalk_sender_staff_id"] = msg.sender_staff_id
task_data = {
"id": task_id,
"name": name,
"enabled": True,
@@ -214,26 +193,21 @@ class SchedulerTool(BaseTool):
}
# Calculate initial next_run_at
next_run = self._calculate_next_run(task)
next_run = self._calculate_next_run(task_data)
if next_run:
task["next_run_at"] = next_run.isoformat()
task_data["next_run_at"] = next_run.isoformat()
# Save task
self.task_store.add_task(task)
self.task_store.add_task(task_data)
# Format response
schedule_desc = self._format_schedule_description(schedule)
receiver_desc = task["action"]["receiver_name"] or task["action"]["receiver"]
receiver_desc = task_data["action"]["receiver_name"] or task_data["action"]["receiver"]
if message:
content_desc = f"💬 消息: {message}"
content_desc = f"💬 固定消息: {message}"
else:
tool_name = tool_call.get("tool_name")
tool_params_str = str(tool_call.get("tool_params", {}))
prefix = tool_call.get("result_prefix", "")
content_desc = f"🔧 工具调用: {tool_name}({tool_params_str})"
if prefix:
content_desc += f"\n📝 结果前缀: {prefix}"
content_desc = f"🤖 AI任务: {ai_task}"
return (
f"✅ 定时任务创建成功\n\n"
@@ -353,9 +327,38 @@ class SchedulerTool(BaseTool):
return {"type": "interval", "seconds": seconds}
elif schedule_type == "once":
# Parse datetime
datetime.fromisoformat(schedule_value)
return {"type": "once", "run_at": schedule_value}
# Parse datetime - support both relative and absolute time
# Check if it's relative time (e.g., "+5s", "+10m", "+1h", "+1d")
if schedule_value.startswith("+"):
import re
match = re.match(r'\+(\d+)([smhd])', schedule_value)
if match:
amount = int(match.group(1))
unit = match.group(2)
from datetime import timedelta
now = datetime.now()
if unit == 's': # seconds
target_time = now + timedelta(seconds=amount)
elif unit == 'm': # minutes
target_time = now + timedelta(minutes=amount)
elif unit == 'h': # hours
target_time = now + timedelta(hours=amount)
elif unit == 'd': # days
target_time = now + timedelta(days=amount)
else:
return None
return {"type": "once", "run_at": target_time.isoformat()}
else:
logger.error(f"[SchedulerTool] Invalid relative time format: {schedule_value}")
return None
else:
# Absolute time in ISO format
datetime.fromisoformat(schedule_value)
return {"type": "once", "run_at": schedule_value}
except Exception as e:
logger.error(f"[SchedulerTool] Invalid schedule: {e}")