Version: 0.9.15.dev.260412

后端:
1. 排程工具从 tools/ 根目录拆分为 tools/schedule 独立子包
- 12 个排程工具文件等价迁入 tools/schedule/,tools/ 根目录仅保留 registry.go 作为统一注册入口
- 所有依赖方(conv / model / node / prompt / service)import 统一切到 schedule 子包
2. Web 搜索工具链落地(tools/web 子包)
- 新增 web_search(结构化检索)与 web_fetch(正文抓取)两个读工具,支持博查 API / mock 降级
- 启动流程按配置选择 provider,未识别类型自动降级为 mock,不阻断主流程
- 执行提示补齐 web 工具使用约束与返回值示例
- config.example.yaml 补齐 websearch 配置段
前端:无
仓库:无
This commit is contained in:
Losita
2026-04-12 19:02:54 +08:00
parent bf1f1defa5
commit 070d4c3459
34 changed files with 1033 additions and 205 deletions

View File

@@ -6,10 +6,12 @@ import (
"strings"
infrarag "github.com/LoveLosita/smartflow/backend/infra/rag"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule"
"github.com/LoveLosita/smartflow/backend/newAgent/tools/web"
)
// ToolHandler 是所有工具的统一执行签名。
type ToolHandler func(state *ScheduleState, args map[string]any) string
type ToolHandler func(state *schedule.ScheduleState, args map[string]any) string
// ToolSchemaEntry 是注入给模型的工具说明快照。
type ToolSchemaEntry struct {
@@ -26,6 +28,9 @@ type ToolSchemaEntry struct {
// 3. 后续新增读工具时,应优先在这里扩展依赖而不是走包级全局变量。
type DefaultRegistryDeps struct {
RAGRuntime infrarag.Runtime
// WebSearchProvider Web 搜索供应商。为 nil 时 web_search / web_fetch 返回"暂未启用",不阻断主流程。
WebSearchProvider web.SearchProvider
}
// ToolRegistry 管理工具注册、查找与执行。
@@ -60,7 +65,7 @@ func (r *ToolRegistry) Register(name, desc, schemaText string, handler ToolHandl
}
// Execute 执行指定工具。
func (r *ToolRegistry) Execute(state *ScheduleState, toolName string, args map[string]any) string {
func (r *ToolRegistry) Execute(state *schedule.ScheduleState, toolName string, args map[string]any) string {
handler, ok := r.handlers[toolName]
if !ok {
return fmt.Sprintf("工具调用失败:未知工具 %q。可用工具%s", toolName, strings.Join(r.ToolNames(), "、"))
@@ -123,72 +128,72 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
r.Register("get_overview",
"获取规划窗口总览(任务视角,全量返回):保留课程占位统计,展开任务清单(过滤课程明细)。",
`{"name":"get_overview","parameters":{}}`,
func(state *ScheduleState, args map[string]any) string {
return GetOverview(state)
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.GetOverview(state)
},
)
r.Register("query_range",
"查看某天或某时段的细粒度占用详情。day 必填slot_start/slot_end 选填(不填查整天)。",
`{"name":"query_range","parameters":{"day":{"type":"int","required":true},"slot_start":{"type":"int"},"slot_end":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
day, ok := argsInt(args, "day")
func(state *schedule.ScheduleState, args map[string]any) string {
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return "查询失败:缺少必填参数 day。"
}
return QueryRange(state, day, argsIntPtr(args, "slot_start"), argsIntPtr(args, "slot_end"))
return schedule.QueryRange(state, day, schedule.ArgsIntPtr(args, "slot_start"), schedule.ArgsIntPtr(args, "slot_end"))
},
)
r.Register("query_available_slots",
"查询候选空位池(先返回纯空位,不足再补可嵌入位),适合 move 前的落点筛选。",
`{"name":"query_available_slots","parameters":{"span":{"type":"int"},"duration":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"},"section_from":{"type":"int"},"section_to":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
return QueryAvailableSlots(state, args)
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueryAvailableSlots(state, args)
},
)
r.Register("query_target_tasks",
"查询候选任务集合,可按 status/week/day/task_id/category 筛选;默认自动入队,供后续 queue_pop_head 逐项处理。",
`{"name":"query_target_tasks","parameters":{"status":{"type":"string","enum":["all","existing","suggested","pending"]},"category":{"type":"string"},"limit":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"task_ids":{"type":"array","items":{"type":"int"}},"task_id":{"type":"int"},"task_item_ids":{"type":"array","items":{"type":"int"}},"task_item_id":{"type":"int"},"enqueue":{"type":"bool"},"reset_queue":{"type":"bool"}}}`,
func(state *ScheduleState, args map[string]any) string {
return QueryTargetTasks(state, args)
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueryTargetTasks(state, args)
},
)
r.Register("queue_pop_head",
"弹出并返回当前队首任务;若已有 current 则复用,保证一次只处理一个任务。",
`{"name":"queue_pop_head","parameters":{}}`,
func(state *ScheduleState, args map[string]any) string {
return QueuePopHead(state, args)
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueuePopHead(state, args)
},
)
r.Register("queue_status",
"查看当前待处理队列状态pending/current/completed/skipped。",
`{"name":"queue_status","parameters":{}}`,
func(state *ScheduleState, args map[string]any) string {
return QueueStatus(state, args)
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueStatus(state, args)
},
)
r.Register("list_tasks",
"列出任务清单可按类别和状态过滤。category 传任务类名称status 仅支持单值 all/existing/suggested/pending。",
`{"name":"list_tasks","parameters":{"category":{"type":"string"},"status":{"type":"string","enum":["all","existing","suggested","pending"]}}}`,
func(state *ScheduleState, args map[string]any) string {
return ListTasks(state, argsStringPtr(args, "category"), argsStringPtr(args, "status"))
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.ListTasks(state, schedule.ArgsStringPtr(args, "category"), schedule.ArgsStringPtr(args, "status"))
},
)
r.Register("get_task_info",
"查询单个任务详细信息,包括类别、状态、占用时段、嵌入关系。",
`{"name":"get_task_info","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")
func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "查询失败:缺少必填参数 task_id。"
}
return GetTaskInfo(state, taskID)
return schedule.GetTaskInfo(state, taskID)
},
)
@@ -196,120 +201,142 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry {
r.Register("place",
"将一个待安排任务预排到指定位置。自动检测可嵌入宿主。task_id/day/slot_start 必填。",
`{"name":"place","parameters":{"task_id":{"type":"int","required":true},"day":{"type":"int","required":true},"slot_start":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")
func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "放置失败:缺少必填参数 task_id。"
}
day, ok := argsInt(args, "day")
day, ok := schedule.ArgsInt(args, "day")
if !ok {
return "放置失败:缺少必填参数 day。"
}
slotStart, ok := argsInt(args, "slot_start")
slotStart, ok := schedule.ArgsInt(args, "slot_start")
if !ok {
return "放置失败:缺少必填参数 slot_start。"
}
return Place(state, taskID, day, slotStart)
return schedule.Place(state, taskID, day, slotStart)
},
)
r.Register("move",
"将一个已预排任务(仅 suggested移动到新位置。existing 属于已安排事实层,不参与 move。task_id/new_day/new_slot_start 必填。",
`{"name":"move","parameters":{"task_id":{"type":"int","required":true},"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")
func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "移动失败:缺少必填参数 task_id。"
}
newDay, ok := argsInt(args, "new_day")
newDay, ok := schedule.ArgsInt(args, "new_day")
if !ok {
return "移动失败:缺少必填参数 new_day。"
}
newSlotStart, ok := argsInt(args, "new_slot_start")
newSlotStart, ok := schedule.ArgsInt(args, "new_slot_start")
if !ok {
return "移动失败:缺少必填参数 new_slot_start。"
}
return Move(state, taskID, newDay, newSlotStart)
return schedule.Move(state, taskID, newDay, newSlotStart)
},
)
r.Register("swap",
"交换两个已落位任务的位置。两个任务必须时长相同。task_a/task_b 必填。",
`{"name":"swap","parameters":{"task_a":{"type":"int","required":true},"task_b":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskA, ok := argsInt(args, "task_a")
func(state *schedule.ScheduleState, args map[string]any) string {
taskA, ok := schedule.ArgsInt(args, "task_a")
if !ok {
return "交换失败:缺少必填参数 task_a。"
}
taskB, ok := argsInt(args, "task_b")
taskB, ok := schedule.ArgsInt(args, "task_b")
if !ok {
return "交换失败:缺少必填参数 task_b。"
}
return Swap(state, taskA, taskB)
return schedule.Swap(state, taskA, taskB)
},
)
r.Register("batch_move",
"原子性批量移动多个任务(仅 suggested最多2条全部成功才生效。若含 existing/pending 或任一冲突将整批失败回滚。",
`{"name":"batch_move","parameters":{"moves":{"type":"array","required":true,"items":{"task_id":"int","new_day":"int","new_slot_start":"int"}}}}`,
func(state *ScheduleState, args map[string]any) string {
moves, err := argsMoveList(args)
func(state *schedule.ScheduleState, args map[string]any) string {
moves, err := schedule.ArgsMoveList(args)
if err != nil {
return fmt.Sprintf("批量移动失败:%s", err.Error())
}
return BatchMove(state, moves)
return schedule.BatchMove(state, moves)
},
)
r.Register("queue_apply_head_move",
"将当前队首任务移动到指定位置并自动出队。仅作用于 current不接受 task_id。new_day/new_slot_start 必填。",
`{"name":"queue_apply_head_move","parameters":{"new_day":{"type":"int","required":true},"new_slot_start":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
return QueueApplyHeadMove(state, args)
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueApplyHeadMove(state, args)
},
)
r.Register("queue_skip_head",
"跳过当前队首任务(不改日程),将其标记为 skipped 并继续后续队列。",
`{"name":"queue_skip_head","parameters":{"reason":{"type":"string"}}}`,
func(state *ScheduleState, args map[string]any) string {
return QueueSkipHead(state, args)
func(state *schedule.ScheduleState, args map[string]any) string {
return schedule.QueueSkipHead(state, args)
},
)
r.Register("min_context_switch",
"在指定任务集合内重排 suggested 任务尽量让同类任务连续以减少上下文切换。仅在用户明确允许打乱顺序时使用。task_ids 必填(兼容 task_id。",
`{"name":"min_context_switch","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
taskIDs, err := parseMinContextSwitchTaskIDs(args)
func(state *schedule.ScheduleState, args map[string]any) string {
taskIDs, err := schedule.ParseMinContextSwitchTaskIDs(args)
if err != nil {
return fmt.Sprintf("减少上下文切换失败:%s。", err.Error())
}
return MinContextSwitch(state, taskIDs)
return schedule.MinContextSwitch(state, taskIDs)
},
)
r.Register("spread_even",
"在给定任务集合内做均匀化铺开先按筛选条件收集候选坑位再规划并原子落地。task_ids 必填(兼容 task_id。",
`{"name":"spread_even","parameters":{"task_ids":{"type":"array","required":true,"items":{"type":"int"}},"task_id":{"type":"int"},"limit":{"type":"int"},"allow_embed":{"type":"bool"},"day":{"type":"int"},"day_start":{"type":"int"},"day_end":{"type":"int"},"day_scope":{"type":"string","enum":["all","workday","weekend"]},"day_of_week":{"type":"array","items":{"type":"int"}},"week":{"type":"int"},"week_filter":{"type":"array","items":{"type":"int"}},"week_from":{"type":"int"},"week_to":{"type":"int"},"slot_type":{"type":"string"},"slot_types":{"type":"array","items":{"type":"string"}},"exclude_sections":{"type":"array","items":{"type":"int"}},"after_section":{"type":"int"},"before_section":{"type":"int"}}}`,
func(state *ScheduleState, args map[string]any) string {
taskIDs, err := parseSpreadEvenTaskIDs(args)
func(state *schedule.ScheduleState, args map[string]any) string {
taskIDs, err := schedule.ParseSpreadEvenTaskIDs(args)
if err != nil {
return fmt.Sprintf("均匀化调整失败:%s。", err.Error())
}
return SpreadEven(state, taskIDs, args)
return schedule.SpreadEven(state, taskIDs, args)
},
)
r.Register("unplace",
"将一个已落位任务移除恢复为待安排状态。会自动清理嵌入关系。task_id 必填。",
`{"name":"unplace","parameters":{"task_id":{"type":"int","required":true}}}`,
func(state *ScheduleState, args map[string]any) string {
taskID, ok := argsInt(args, "task_id")
func(state *schedule.ScheduleState, args map[string]any) string {
taskID, ok := schedule.ArgsInt(args, "task_id")
if !ok {
return "移除失败:缺少必填参数 task_id。"
}
return Unplace(state, taskID)
return schedule.Unplace(state, taskID)
},
)
// --- Web 搜索读工具 ---
// 1. provider 为 nil 时 handler 返回"暂未启用"的 observation不会阻断主流程
// 2. 两个工具均为读操作,走 action=continue + tool_call 模式。
webSearchHandler := web.NewSearchToolHandler(deps.WebSearchProvider)
webFetchHandler := web.NewFetchToolHandler(web.NewFetcher())
r.Register("web_search",
"Web 搜索:根据 query 返回结构化检索结果(标题/摘要/URL/来源域名/时间。query 必填。",
`{"name":"web_search","parameters":{"query":{"type":"string","required":true},"top_k":{"type":"int"},"domain_allow":{"type":"array","items":{"type":"string"}},"recency_days":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
return webSearchHandler.Handle(args)
},
)
r.Register("web_fetch",
"抓取指定 URL 的正文内容并做最小 HTML 清洗。url 必填。",
`{"name":"web_fetch","parameters":{"url":{"type":"string","required":true},"max_chars":{"type":"int"}}}`,
func(state *schedule.ScheduleState, args map[string]any) string {
return webFetchHandler.Handle(args)
},
)

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import "fmt"
@@ -7,7 +7,7 @@ import "fmt"
// JSON 反序列化后数字默认为 float64字符串为 string需要类型断言。
// argsInt 从 map 中提取 int 值。支持 float64JSON 反序列化的默认类型)。
func argsInt(args map[string]any, key string) (int, bool) {
func ArgsInt(args map[string]any, key string) (int, bool) {
v, ok := args[key]
if !ok {
return 0, false
@@ -22,7 +22,7 @@ func argsInt(args map[string]any, key string) (int, bool) {
}
// argsString 从 map 中提取 string 值。
func argsString(args map[string]any, key string) (string, bool) {
func ArgsString(args map[string]any, key string) (string, bool) {
v, ok := args[key]
if !ok {
return "", false
@@ -32,8 +32,8 @@ func argsString(args map[string]any, key string) (string, bool) {
}
// argsIntPtr 从 map 中提取可选 int 值,不存在返回 nil。
func argsIntPtr(args map[string]any, key string) *int {
v, ok := argsInt(args, key)
func ArgsIntPtr(args map[string]any, key string) *int {
v, ok := ArgsInt(args, key)
if !ok {
return nil
}
@@ -41,8 +41,8 @@ func argsIntPtr(args map[string]any, key string) *int {
}
// argsStringPtr 从 map 中提取可选 string 值,不存在返回 nil。
func argsStringPtr(args map[string]any, key string) *string {
v, ok := argsString(args, key)
func ArgsStringPtr(args map[string]any, key string) *string {
v, ok := ArgsString(args, key)
if !ok {
return nil
}
@@ -50,7 +50,7 @@ func argsStringPtr(args map[string]any, key string) *string {
}
// argsIntSlice 从 map 中提取 int 数组,支持 []any / []int / []float64。
func argsIntSlice(args map[string]any, key string) ([]int, bool) {
func ArgsIntSlice(args map[string]any, key string) ([]int, bool) {
v, ok := args[key]
if !ok {
return nil, false
@@ -88,7 +88,7 @@ func argsIntSlice(args map[string]any, key string) ([]int, bool) {
}
// argsMoveList 从 map 中提取 batch_move 的 moves 数组。
func argsMoveList(args map[string]any) ([]MoveRequest, error) {
func ArgsMoveList(args map[string]any) ([]MoveRequest, error) {
v, ok := args["moves"]
if !ok {
return nil, fmt.Errorf("缺少 moves 参数")
@@ -103,15 +103,15 @@ func argsMoveList(args map[string]any) ([]MoveRequest, error) {
if !ok {
return nil, fmt.Errorf("moves[%d] 不是有效对象", i)
}
taskID, ok := argsInt(m, "task_id")
taskID, ok := ArgsInt(m, "task_id")
if !ok {
return nil, fmt.Errorf("moves[%d].task_id 缺失或无效", i)
}
newDay, ok := argsInt(m, "new_day")
newDay, ok := ArgsInt(m, "new_day")
if !ok {
return nil, fmt.Errorf("moves[%d].new_day 缺失或无效", i)
}
newSlotStart, ok := argsInt(m, "new_slot_start")
newSlotStart, ok := ArgsInt(m, "new_slot_start")
if !ok {
return nil, fmt.Errorf("moves[%d].new_slot_start 缺失或无效", i)
}

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"encoding/json"
@@ -305,19 +305,19 @@ func SpreadEven(state *ScheduleState, taskIDs []int, args map[string]any) string
return strings.TrimSpace(sb.String())
}
func parseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) {
return parseCompositeTaskIDs(args)
func ParseMinContextSwitchTaskIDs(args map[string]any) ([]int, error) {
return ParseCompositeTaskIDs(args)
}
func parseSpreadEvenTaskIDs(args map[string]any) ([]int, error) {
return parseCompositeTaskIDs(args)
func ParseSpreadEvenTaskIDs(args map[string]any) ([]int, error) {
return ParseCompositeTaskIDs(args)
}
func parseCompositeTaskIDs(args map[string]any) ([]int, error) {
if ids, ok := argsIntSlice(args, "task_ids"); ok && len(ids) > 0 {
func ParseCompositeTaskIDs(args map[string]any) ([]int, error) {
if ids, ok := ArgsIntSlice(args, "task_ids"); ok && len(ids) > 0 {
return ids, nil
}
if id, ok := argsInt(args, "task_id"); ok {
if id, ok := ArgsInt(args, "task_id"); ok {
return []int{id}, nil
}
return nil, fmt.Errorf("缺少必填参数 task_ids兼容单值 task_id")

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"encoding/json"
@@ -117,7 +117,7 @@ func QueueApplyHeadMove(state *ScheduleState, args map[string]any) string {
}, "queue_apply_head_move")
}
newDay, ok := argsInt(args, "new_day")
newDay, ok := ArgsInt(args, "new_day")
if !ok {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
@@ -130,7 +130,7 @@ func QueueApplyHeadMove(state *ScheduleState, args map[string]any) string {
Result: "缺少必填参数 new_day。",
}, "queue_apply_head_move")
}
newSlotStart, ok := argsInt(args, "new_slot_start")
newSlotStart, ok := ArgsInt(args, "new_slot_start")
if !ok {
return mustJSON(queueApplyHeadMoveResult{
Tool: "queue_apply_head_move",
@@ -190,7 +190,7 @@ func QueueSkipHead(state *ScheduleState, args map[string]any) string {
}
reason := ""
if raw, ok := argsString(args, "reason"); ok {
if raw, ok := ArgsString(args, "reason"); ok {
reason = strings.TrimSpace(raw)
}
markCurrentTaskSkipped(state)

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"encoding/json"
@@ -850,7 +850,7 @@ func inferWeekBounds(state *ScheduleState) (int, int) {
// readIntAny 按别名顺序读取 int 参数。
func readIntAny(args map[string]any, keys ...string) (int, bool) {
for _, key := range keys {
value, ok := argsInt(args, key)
value, ok := ArgsInt(args, key)
if ok {
return value, true
}
@@ -861,7 +861,7 @@ func readIntAny(args map[string]any, keys ...string) (int, bool) {
// readStringAny 按别名顺序读取 string 参数。
func readStringAny(args map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := argsString(args, key); ok {
if value, ok := ArgsString(args, key); ok {
return value
}
}
@@ -894,7 +894,7 @@ func readBoolAnyWithDefault(args map[string]any, defaultValue bool, keys ...stri
// readIntSliceAny 按别名顺序读取 int 列表参数。
func readIntSliceAny(args map[string]any, keys ...string) []int {
for _, key := range keys {
if values, ok := argsIntSlice(args, key); ok {
if values, ok := ArgsIntSlice(args, key); ok {
return values
}
}

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
// TaskProcessingQueue 表示 execute 阶段的“逐项处理队列”运行态。
//

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
// DayMapping maps a day_index to a real (week, day_of_week) coordinate.
type DayMapping struct {

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import "slices"

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package newagenttools
package schedule
import (
"fmt"

View File

@@ -0,0 +1,175 @@
package web
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// Fetcher 抓取指定 URL 正文并做最小 HTML 清洗。
//
// 职责:
// 1. 发起 HTTP GET 请求并读取响应体;
// 2. 剥离 HTML 标签,保留纯文本内容;
// 3. 按 MaxChars 截断,避免超长正文占用模型上下文。
//
// 不负责:
// 1. 不负责 JS 渲染(无法处理 SPA 页面);
// 2. 不负责反爬绕过(遇到 403 直接返回错误);
// 3. 不负责正文提取算法优化(仅做粗粒度标签剥离)。
type Fetcher struct {
// Client 带超时的 HTTP 客户端,由调用方注入。
Client *http.Client
// MaxChars 正文最大字符数。超出时截断并标记 truncated=true。0 使用默认值 4000。
MaxChars int
}
// NewFetcher 创建默认 Fetcher。
//
// 1. 超时默认 10 秒,足够覆盖大多数静态页面;
// 2. MaxChars 默认 4000 字符,约占 1000~2000 token不会挤占过多上下文。
func NewFetcher() *Fetcher {
return &Fetcher{
Client: &http.Client{
Timeout: 10 * time.Second,
},
MaxChars: 4000,
}
}
// FetchResult 抓取结果。
type FetchResult struct {
// Title 页面标题(从 <title> 标签提取)。
Title string
// Content 清洗后的纯文本正文。
Content string
// Truncated 正文是否被截断。
Truncated bool
}
// Fetch 抓取指定 URL 并返回清洗后的正文。
//
// 流程:
// 1. 构建带超时的 HTTP GET 请求;
// 2. 检查状态码,非 2xx 直接返回可读错误;
// 3. 读取响应体,提取 <title>
// 4. 剥离 HTML 标签,按 MaxChars 截断;
// 5. 所有失败场景返回 error由工具层兜底组装 observation。
func (f *Fetcher) Fetch(ctx context.Context, url string) (*FetchResult, error) {
// 1. 构建请求,注入 ctx 用于超时与取消。
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("构建请求失败:%w", err)
}
// 2. 模拟浏览器 User-Agent避免部分站点直接拒绝。
req.Header.Set("User-Agent", "SmartFlow-Agent/1.0 (compatible; web_fetch)")
resp, err := f.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求失败:%w", err)
}
defer resp.Body.Close()
// 3. 非 2xx 返回明确状态码,方便工具层区分 4xx/5xx。
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d%s", resp.StatusCode, resp.Status)
}
// 4. 限制读取量(最多 1MB防止恶意超长响应撑爆内存。
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("读取响应体失败:%w", err)
}
htmlStr := string(body)
// 5. 提取 <title> 内容。
title := extractHTMLTitle(htmlStr)
// 6. 剥离 HTML 标签,得到纯文本。
text := stripHTMLTags(htmlStr)
// 7. 清理多余空白(连续换行、行首行尾空格)。
text = cleanWhitespace(text)
// 8. 按 MaxChars 截断。
maxChars := f.MaxChars
if maxChars <= 0 {
maxChars = 4000
}
truncated := false
runes := []rune(text)
if len(runes) > maxChars {
truncated = true
runes = runes[:maxChars]
}
return &FetchResult{
Title: title,
Content: string(runes),
Truncated: truncated,
}, nil
}
// extractHTMLTitle 从 HTML 中提取 <title> 标签内容。
//
// 1. 使用正则匹配,不做 DOM 解析(兼顾性能与简单性);
// 2. 找不到时返回空字符串,不报错。
func extractHTMLTitle(htmlStr string) string {
re := regexp.MustCompile("(?i)<title[^>]*>(.*?)</title>")
matches := re.FindStringSubmatch(htmlStr)
if len(matches) >= 2 {
return strings.TrimSpace(matches[1])
}
return ""
}
// stripHTMLTags 剥离所有 HTML 标签,保留纯文本。
//
// 1. 先移除 <script> / <style> 块(避免 JS/CSS 内容污染正文);
// 2. 再移除所有 HTML 标签;
// 3. 解码常见 HTML 实体(&amp; &lt; &gt; &quot;)。
func stripHTMLTags(htmlStr string) string {
// 1. 移除 script/style 块
re := regexp.MustCompile("(?is)<(script|style)[^>]*>.*?</\\1>")
text := re.ReplaceAllString(htmlStr, " ")
// 2. 移除所有 HTML 标签
reTag := regexp.MustCompile("<[^>]+>")
text = reTag.ReplaceAllString(text, " ")
// 3. 解码常见 HTML 实体
text = strings.ReplaceAll(text, "&amp;", "&")
text = strings.ReplaceAll(text, "&lt;", "<")
text = strings.ReplaceAll(text, "&gt;", ">")
text = strings.ReplaceAll(text, "&quot;", "\"")
text = strings.ReplaceAll(text, "&#39;", "'")
text = strings.ReplaceAll(text, "&nbsp;", " ")
return text
}
// cleanWhitespace 清理多余空白:连续空行合并为单个换行,去除行首行尾空格。
func cleanWhitespace(text string) string {
// 1. 连续换行压缩为最多两个换行(保留段落分隔感)。
re := regexp.MustCompile("\\n{3,}")
text = re.ReplaceAllString(text, "\n\n")
// 2. 按行去除首尾空白后重新拼装。
lines := strings.Split(text, "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
cleaned = append(cleaned, trimmed)
}
return strings.Join(cleaned, "\n")
}

View File

@@ -0,0 +1,82 @@
package web
import (
"context"
"time"
)
// SearchProvider 搜索供应商抽象接口。
//
// 职责:
// 1. 接收检索查询与选项,返回结构化搜索结果;
// 2. 实现方负责 HTTP 调用、错误重试、限流兜底;
// 3. 调用方不感知底层是 Bocha / Mock 还是其他供应商。
//
// 不负责:
// 1. 不负责 URL 正文抓取(由 Fetcher 承担);
// 2. 不负责结果缓存(由上层工具决定)。
type SearchProvider interface {
// Name 返回供应商名称(如 "mock"、"bocha"),用于日志与降级标识。
Name() string
// Search 执行一次检索。
//
// 1. ctx 用于超时控制与取消;
// 2. opts.TopK 默认 5上限 20超出自动截断
// 3. 失败时返回 error调用方负责兜底 observation 组装。
Search(ctx context.Context, query string, opts SearchOptions) (*SearchResponse, error)
}
// SearchOptions 搜索可选参数。
type SearchOptions struct {
// TopK 返回结果数上限。0 表示使用供应商默认值(通常为 5
TopK int
// DomainAllow 仅返回指定域名下的结果。空表示不限。
DomainAllow []string
// RecencyDays 仅返回最近 N 天内的结果。0 表示不限时间。
RecencyDays int
}
// SearchResponse 搜索结果集合。
type SearchResponse struct {
// Query 原始查询文本,用于日志追踪。
Query string
// Items 搜索结果条目,按相关性降序排列。
Items []SearchItem
}
// SearchItem 单条搜索结果。
type SearchItem struct {
// Title 页面标题。
Title string
// URL 页面链接。
URL string
// Snippet 搜索引擎返回的摘要片段。
Snippet string
// Domain 来源域名(如 "example.com"),由实现方从 URL 提取。
Domain string
// PublishedAt 页面发布时间(若供应商可提供)。零值表示未知。
PublishedAt time.Time
// Raw 供应商原始响应字段,供调试用,不传给模型。
Raw map[string]any
}
// normalizeTopK 将用户传入的 topK 归一化到 [1, max] 区间。
// 默认值 5上限 20防止模型传入异常值导致 API 爆炸。
func normalizeTopK(topK, defaultVal, maxVal int) int {
if topK <= 0 {
return defaultVal
}
if topK > maxVal {
return maxVal
}
return topK
}

View File

@@ -0,0 +1,217 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// BochaProvider 博查Bocha搜索供应商实现。
//
// 职责:
// 1. 将 SearchOptions 映射为博查 API 请求参数;
// 2. 发起 HTTP POST 调用博查 web-search 端点;
// 3. 将博查响应转换为统一的 SearchResponse 结构。
//
// 不负责:
// 1. 不负责 API Key 管理(由调用方注入);
// 2. 不负责重试(单次调用失败直接返回 error
// 3. 不负责 URL 正文抓取(由 Fetcher 承担)。
//
// 博查 API 文档https://open.bochaai.com/
type BochaProvider struct {
// apiKey 博查 API Key从配置注入。
apiKey string
// httpClient 带超时的 HTTP 客户端。
httpClient *http.Client
// baseURL 博查 API 基础地址,默认 https://api.bochaai.com/v1。
baseURL string
}
// NewBochaProvider 创建博查搜索供应商。
//
// 1. apiKey 必填,为空时 Search 会返回明确错误;
// 2. 超时默认 10 秒,与工具层 ctx 超时对齐;
// 3. baseURL 留空则使用默认地址。
func NewBochaProvider(apiKey, baseURL string) *BochaProvider {
if baseURL == "" {
baseURL = "https://api.bochaai.com/v1"
}
return &BochaProvider{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
baseURL: baseURL,
}
}
// Name 返回供应商标识。
func (b *BochaProvider) Name() string { return "bocha" }
// Search 调用博查 web-search API 执行检索。
//
// 流程:
// 1. 参数校验apiKey 非空、query 非空);
// 2. 将 SearchOptions 映射为博查请求体count / freshness / summary
// 3. 发起 HTTP POST读取响应
// 4. 解析博查 JSON 响应,提取 webPages.value 数组;
// 5. 转换为统一 SearchItem 结构返回。
//
// 错误处理:
// - apiKey 为空 → 返回明确错误;
// - HTTP 非 2xx → 返回带状态码的错误;
// - 响应解析失败 → 返回原始响应片段供排查。
func (b *BochaProvider) Search(ctx context.Context, query string, opts SearchOptions) (*SearchResponse, error) {
// 1. 参数校验。
if b.apiKey == "" {
return nil, fmt.Errorf("博查 API Key 未配置")
}
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("查询关键词为空")
}
// 2. 组装请求体。
// 2.1 count博查支持 1~50默认 10。
count := normalizeTopK(opts.TopK, 10, 50)
// 2.2 freshness将 RecencyDays 映射为博查的时间过滤枚举。
freshness := mapRecencyDaysToFreshness(opts.RecencyDays)
reqBody := bochaSearchRequest{
Query: query,
Count: count,
Freshness: freshness,
Summary: true, // 开启 AI 摘要,提升结果信息密度
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求体失败:%w", err)
}
// 3. 发起 HTTP POST。
url := b.baseURL + "/web-search"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("构建请求失败:%w", err)
}
req.Header.Set("Authorization", "Bearer "+b.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求博查 API 失败:%w", err)
}
defer resp.Body.Close()
// 4. 读取响应体(限制 2MB防止异常响应撑爆内存
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return nil, fmt.Errorf("读取博查响应失败:%w", err)
}
// 5. 检查 HTTP 状态码。
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 截取前 200 字符作为错误上下文,避免日志过长。
snippet := string(respBody)
if len(snippet) > 200 {
snippet = snippet[:200]
}
return nil, fmt.Errorf("博查 API 返回 HTTP %d%s", resp.StatusCode, snippet)
}
// 6. 解析响应 JSON。
var bochaResp bochaSearchAPIResponse
if err := json.Unmarshal(respBody, &bochaResp); err != nil {
return nil, fmt.Errorf("解析博查响应失败:%w", err)
}
// 7. 提取搜索结果。
items := make([]SearchItem, 0, len(bochaResp.Data.WebPages.Value))
for _, v := range bochaResp.Data.WebPages.Value {
item := SearchItem{
Title: v.Name,
URL: v.URL,
Snippet: v.Summary, // 优先使用 AI 摘要;若为空则回退到 snippet
Domain: v.SiteName,
}
if item.Snippet == "" {
item.Snippet = v.Snippet
}
// 解析发布时间博查格式2024-07-22T00:00:00+08:00
if v.DatePublished != "" {
if t, err := time.Parse(time.RFC3339, v.DatePublished); err == nil {
item.PublishedAt = t
}
}
items = append(items, item)
}
return &SearchResponse{
Query: query,
Items: items,
}, nil
}
// mapRecencyDaysToFreshness 将 RecencyDays 映射为博查 freshness 枚举值。
//
// 映射规则:
// - 0 → noLimit不限时间
// - 1 → oneDay
// - 2~7 → oneWeek
// - 8~30 → oneMonth
// - 31~365 → oneYear
// - >365 → noLimit
func mapRecencyDaysToFreshness(days int) string {
switch {
case days <= 0:
return "noLimit"
case days <= 1:
return "oneDay"
case days <= 7:
return "oneWeek"
case days <= 30:
return "oneMonth"
case days <= 365:
return "oneYear"
default:
return "noLimit"
}
}
// ==================== 博查 API 请求/响应结构体 ====================
// bochaSearchRequest 博查 web-search 请求体。
type bochaSearchRequest struct {
Query string `json:"query"`
Count int `json:"count"`
Freshness string `json:"freshness"`
Summary bool `json:"summary"`
}
// bochaSearchAPIResponse 博查 web-search 响应体(只提取需要的字段)。
type bochaSearchAPIResponse struct {
Data struct {
WebPages struct {
Value []bochaWebPageItem `json:"value"`
} `json:"webPages"`
} `json:"data"`
}
// bochaWebPageItem 博查单条搜索结果。
type bochaWebPageItem struct {
Name string `json:"name"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Summary string `json:"summary"`
SiteName string `json:"siteName"`
DatePublished string `json:"datePublished"`
}

View File

@@ -0,0 +1,58 @@
package web
import (
"context"
"fmt"
"time"
)
// MockProvider 空实现搜索供应商,返回硬编码结果。
//
// 用途:
// 1. 在真实 API Key 到手前先跑通工具注册→调用→observation 写回的完整链路;
// 2. 后续替换为 Tavily/Brave 实现后Mock 保留用于单元测试。
//
// 不负责:
// 1. 不负责真实 HTTP 调用;
// 2. 不负责网络错误模拟(如需测试超时可另行实现 TimeoutMockProvider
type MockProvider struct{}
// Name 返回供应商标识。
func (m *MockProvider) Name() string { return "mock" }
// Search 返回 2 条硬编码搜索结果,模拟正常检索链路。
//
// 1. 无论 query 内容如何,始终返回相同结果;
// 2. ctx 仅做形式兼容,不检查超时;
// 3. 永远不返回 errorMock 不模拟失败场景)。
func (m *MockProvider) Search(_ context.Context, query string, opts SearchOptions) (*SearchResponse, error) {
topK := normalizeTopK(opts.TopK, 5, 20)
// 1. 准备 2 条模拟数据覆盖核心字段title/url/snippet/domain/published_at
// 2. 若调用方 topK=1 则只返回第一条。
mockItems := []SearchItem{
{
Title: fmt.Sprintf("搜索结果示例 - %s", query),
URL: "https://example.com/search-result-1",
Snippet: "这是 MockProvider 返回的模拟搜索摘要,用于验证工具链路是否通畅。",
Domain: "example.com",
PublishedAt: time.Now().Add(-24 * time.Hour),
},
{
Title: fmt.Sprintf("相关资料 - %s", query),
URL: "https://example.com/related-resource-2",
Snippet: "这是第二条 Mock 结果,模拟同主题下的补充信息来源。",
Domain: "example.com",
PublishedAt: time.Now().Add(-48 * time.Hour),
},
}
if topK < len(mockItems) {
mockItems = mockItems[:topK]
}
return &SearchResponse{
Query: query,
Items: mockItems,
}, nil
}

View File

@@ -0,0 +1,227 @@
package web
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"time"
)
// SearchToolHandler web_search 工具 handler。
//
// 职责:
// 1. 解析 argsquery / top_k / domain_allow / recency_days
// 2. 调用 SearchProvider 执行检索;
// 3. 组装结构化 JSON observation 返回给模型。
//
// 不负责:
// 1. 不负责 provider 生命周期管理(由注册层注入);
// 2. 不负责重试provider 内部处理)。
type SearchToolHandler struct {
provider SearchProvider
}
// NewSearchToolHandler 创建 web_search 工具 handler。
//
// 1. provider 为 nil 时Handle 返回"搜索暂未启用"的 observation
// 2. 这样做的好处是:即使未配置 provider也不会阻断主流程。
func NewSearchToolHandler(provider SearchProvider) *SearchToolHandler {
return &SearchToolHandler{provider: provider}
}
// searchToolArgs web_search 工具的参数定义。
type searchToolArgs struct {
Query string `json:"query"`
TopK int `json:"top_k"`
DomainAllow []string `json:"domain_allow"`
RecencyDays int `json:"recency_days"`
}
// searchToolResult web_search 工具的输出结构。
type searchToolResult struct {
Tool string `json:"tool"`
Query string `json:"query"`
Count int `json:"count"`
Items []searchItem `json:"items"`
}
// searchItem 输出给模型的单条搜索结果。
type searchItem struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Domain string `json:"domain"`
PublishedAt string `json:"published_at,omitempty"`
}
// Handle 执行 web_search 工具。
//
// 1. 解析参数query 为必填,缺失时直接返回错误 observation
// 2. 调用 provider.Search超时上限 10 秒;
// 3. 失败时返回可恢复 observation包含错误原因不 panic、不阻断主流程。
func (h *SearchToolHandler) Handle(args map[string]any) string {
// 1. provider 为 nil 说明未启用,直接返回提示。
if h.provider == nil {
return `{"tool":"web_search","error":"搜索暂未启用,请跳过 web_search 继续执行其他操作。"}`
}
// 2. 提取必填参数 query。
query, _ := args["query"].(string)
query = strings.TrimSpace(query)
if query == "" {
return `{"tool":"web_search","error":"参数错误:缺少必填参数 query。"}`
}
// 3. 提取可选参数。
topK, _ := args["top_k"].(float64)
var domainAllow []string
if raw, ok := args["domain_allow"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
domainAllow = append(domainAllow, s)
}
}
}
recencyDays, _ := args["recency_days"].(float64)
// 4. 构建带超时的 context防止搜索请求卡死。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 5. 调用 provider。
start := time.Now()
resp, err := h.provider.Search(ctx, query, SearchOptions{
TopK: int(topK),
DomainAllow: domainAllow,
RecencyDays: int(recencyDays),
})
elapsed := time.Since(start)
// 6. 记录日志,方便排查搜索耗时、结果数、失败原因。
log.Printf("[web_search] provider=%s query=%q topK=%d elapsed=%s results=%d err=%v",
h.provider.Name(), query, int(topK), elapsed, len(resp.Items), err)
if err != nil {
// 7. 失败时返回可恢复 observation模型看到后可选择换 query 或跳过。
return fmt.Sprintf(`{"tool":"web_search","error":"搜索失败:%s","query":%q}`, err.Error(), query)
}
// 8. 组装输出 JSON。
items := make([]searchItem, 0, len(resp.Items))
for _, item := range resp.Items {
si := searchItem{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Domain: item.Domain,
}
if !item.PublishedAt.IsZero() {
si.PublishedAt = item.PublishedAt.Format("2006-01-02")
}
items = append(items, si)
}
result := searchToolResult{
Tool: "web_search",
Query: query,
Count: len(items),
Items: items,
}
out, err := json.Marshal(result)
if err != nil {
return fmt.Sprintf(`{"tool":"web_search","error":"序列化结果失败:%s"}`, err.Error())
}
return string(out)
}
// FetchToolHandler web_fetch 工具 handler。
//
// 职责:
// 1. 解析 argsurl / max_chars
// 2. 调用 Fetcher 抓取并清洗正文;
// 3. 组装 JSON observation 返回给模型。
type FetchToolHandler struct {
fetcher *Fetcher
}
// NewFetchToolHandler 创建 web_fetch 工具 handler。
func NewFetchToolHandler(fetcher *Fetcher) *FetchToolHandler {
return &FetchToolHandler{fetcher: fetcher}
}
// fetchToolResult web_fetch 工具的输出结构。
type fetchToolResult struct {
Tool string `json:"tool"`
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
}
// Handle 执行 web_fetch 工具。
//
// 1. 解析参数url 为必填;
// 2. max_chars 可选,为 0 时使用 Fetcher 默认值4000
// 3. 所有失败场景返回结构化错误 observation不 panic。
func (h *FetchToolHandler) Handle(args map[string]any) string {
// 1. fetcher 为 nil 说明未初始化。
if h.fetcher == nil {
return `{"tool":"web_fetch","error":"抓取服务暂未初始化,请跳过 web_fetch 继续执行。"}`
}
// 2. 提取必填参数 url。
url, _ := args["url"].(string)
url = strings.TrimSpace(url)
if url == "" {
return `{"tool":"web_fetch","error":"参数错误:缺少必填参数 url。"}`
}
// 3. 提取可选参数 max_chars覆盖 Fetcher 默认值。
maxChars := 0
if v, ok := args["max_chars"].(float64); ok {
maxChars = int(v)
}
// 4. 若调用方指定 max_chars临时覆盖 Fetcher 配置。
savedMaxChars := h.fetcher.MaxChars
if maxChars > 0 {
h.fetcher.MaxChars = maxChars
}
defer func() {
h.fetcher.MaxChars = savedMaxChars
}()
// 5. 构建带超时的 context。
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// 6. 调用 Fetcher。
start := time.Now()
result, err := h.fetcher.Fetch(ctx, url)
elapsed := time.Since(start)
log.Printf("[web_fetch] url=%q elapsed=%s truncated=%v err=%v", url, elapsed, result != nil && result.Truncated, err)
if err != nil {
// 7. 失败时返回可恢复 observation。
return fmt.Sprintf(`{"tool":"web_fetch","error":"抓取失败:%s","url":%q}`, err.Error(), url)
}
// 8. 组装输出 JSON。
out := fetchToolResult{
Tool: "web_fetch",
URL: url,
Title: result.Title,
Content: result.Content,
Truncated: result.Truncated,
}
raw, err := json.Marshal(out)
if err != nil {
return fmt.Sprintf(`{"tool":"web_fetch","error":"序列化结果失败:%s"}`, err.Error())
}
return string(raw)
}