mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
v4 architecture: pipeline stages, SDK, event bus, LLM-driven memory consolidation
- SDK PluginAPI (internal/plugin/sdk/): RegisterTool/RegisterStage/Subscribe/Publish - EventBus (internal/events/): system-level pub/sub with wildcard support - StageHost (internal/agent/core/stages.go): 7-stage message pipeline - Agent core: on_input/pre_action/post_action/before_toolcall/after_toolcall/before_output/after_output - Plugin Registry: SDK plugin registration and tool routing - GraphDB.MergeEntities: entity consolidation with relation redirection - memory_merge tool: allows LLM to merge similar entities - Consolidation task: heartbeat detects conflicts, enqueues via IO for LLM decision - _consolidation_ internal channel for system-level memory maintenance - Comprehensive documentation: ARCHITECTURE.md, PLAN.md, DESIGN.md, README.md - 54 tests across all packages, all passing
This commit is contained in:
@ -11,10 +11,12 @@ import (
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
@ -59,6 +61,10 @@ type Agent struct {
|
||||
|
||||
// 当前请求的输出通道(mutex 保护,process() 内独占)
|
||||
currentOutputChannel string
|
||||
|
||||
// 阶段管道:插件消息流编辑
|
||||
stageHost *StageHost
|
||||
eventBus *events.Bus
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
@ -78,7 +84,10 @@ type AgentConfig struct {
|
||||
PluginReg *plugin.Registry
|
||||
PluginDir string
|
||||
DistillInterval time.Duration
|
||||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||||
ContextSavePath string // 上下文持久化路径,空则不持久化
|
||||
StageHost *StageHost
|
||||
EventBus *events.Bus
|
||||
}
|
||||
|
||||
func New(cfg AgentConfig) *Agent {
|
||||
@ -100,7 +109,7 @@ func New(cfg AgentConfig) *Agent {
|
||||
indexer: cfg.Indexer,
|
||||
skills: cfg.Skills,
|
||||
tracker: cfg.Tracker,
|
||||
context: NewRelevanceContext(),
|
||||
context: NewRelevanceContext(cfg.ContextSavePath),
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
@ -112,6 +121,8 @@ func New(cfg AgentConfig) *Agent {
|
||||
pluginDir: cfg.PluginDir,
|
||||
distillInterval: cfg.DistillInterval,
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
stageHost: cfg.StageHost,
|
||||
eventBus: cfg.EventBus,
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,13 +179,31 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
a.currentOutputChannel = evt.Source
|
||||
}
|
||||
|
||||
// 记忆整理任务:不路由到外部输出通道
|
||||
if evt.OutputChannel == "_consolidation_" {
|
||||
a.processConsolidation(input)
|
||||
return
|
||||
}
|
||||
|
||||
// === Stage: on_input — 消息到达,插件可拦截 ===
|
||||
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
|
||||
a.publishEvent(events.EventRawInput, map[string]interface{}{
|
||||
"content": input,
|
||||
"source": evt.Source,
|
||||
})
|
||||
if a.runStage(sdk.StageOnInput, stageCtx) {
|
||||
a.emitResponse(evt, *stageCtx.Response)
|
||||
return
|
||||
}
|
||||
input = stageCtx.RawMessage
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: evt.Source,
|
||||
Input: input,
|
||||
})
|
||||
|
||||
response, toolsUsed, err := a.process(input)
|
||||
response, toolsUsed, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process error: %v", err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
@ -206,6 +235,14 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
}
|
||||
|
||||
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
// === Stage: before_output — 最终文本就绪,插件可改写 ===
|
||||
stageCtx := &sdk.StageContext{
|
||||
FinalText: response,
|
||||
Phase: sdk.StageBeforeOutput,
|
||||
}
|
||||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||||
response = stageCtx.FinalText
|
||||
|
||||
// 读取当前输出通道(可能已被 AI 通过 output_set_channel 切换)
|
||||
ch := a.currentOutputChannel
|
||||
if ch == "" {
|
||||
@ -230,10 +267,19 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
OutputChannel: ch,
|
||||
}
|
||||
}
|
||||
|
||||
// === Stage: after_output — 输出完成,插件只读 ===
|
||||
a.publishEvent(events.EventAgentOutput, map[string]interface{}{
|
||||
"content": response,
|
||||
"channel": ch,
|
||||
"source": evt.Source,
|
||||
})
|
||||
stageCtx.Phase = sdk.StageAfterOutput
|
||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||||
}
|
||||
|
||||
// process — 内部处理,带工具循环
|
||||
func (a *Agent) process(input string) (response string, toolsUsed []string, err error) {
|
||||
// process — 内部处理,带工具循环和阶段管道
|
||||
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, err error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
@ -248,6 +294,20 @@ func (a *Agent) process(input string) (response string, toolsUsed []string, err
|
||||
a.personality != nil && a.personality.Content != "",
|
||||
a.docStoreSize())
|
||||
|
||||
// === Stage: pre_action — 上下文就绪,即将调用 LLM ===
|
||||
if a.runStage(sdk.StagePreAction, stageCtx) {
|
||||
return *stageCtx.Response, toolsUsed, nil
|
||||
}
|
||||
if len(stageCtx.ContextMsgs) > 0 {
|
||||
for _, m := range stageCtx.ContextMsgs {
|
||||
role, _ := m["role"].(string)
|
||||
content, _ := m["content"].(string)
|
||||
if role != "" {
|
||||
msgs = append(msgs, agentAPI.Message{Role: role, Content: content})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for turn := 0; turn < a.maxTurns; turn++ {
|
||||
req := &agentAPI.CompletionRequest{
|
||||
Messages: msgs,
|
||||
@ -264,6 +324,15 @@ func (a *Agent) process(input string) (response string, toolsUsed []string, err
|
||||
return "", toolsUsed, fmt.Errorf("provider: %w", err)
|
||||
}
|
||||
|
||||
// === Stage: post_action — LLM 返回,插件可审查/修改 ===
|
||||
stageCtx.LLMText = resp.Content
|
||||
stageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
|
||||
if a.runStage(sdk.StagePostAction, stageCtx) {
|
||||
return *stageCtx.Response, toolsUsed, nil
|
||||
}
|
||||
resp.Content = stageCtx.LLMText
|
||||
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, nil
|
||||
}
|
||||
@ -271,16 +340,74 @@ func (a *Agent) process(input string) (response string, toolsUsed []string, err
|
||||
for _, tc := range resp.ToolCalls {
|
||||
toolsUsed = append(toolsUsed, tc.Name)
|
||||
log.Printf("[agent] executing tool: %s (id=%s)", tc.Name, tc.ID)
|
||||
|
||||
// === Stage: before_toolcall — 插件可拒绝/改参 ===
|
||||
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
|
||||
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
|
||||
stageCtx.ToolResults = nil
|
||||
if a.runStage(sdk.StageBeforeToolcall, stageCtx) {
|
||||
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "denied",
|
||||
})
|
||||
continue
|
||||
}
|
||||
tc.Arguments = stageCtx.ToolCalls[0].Arguments
|
||||
|
||||
result := a.executeToolCall(tc)
|
||||
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
|
||||
|
||||
// === Stage: after_toolcall — 插件可改结果 ===
|
||||
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Success: true, Result: result}}
|
||||
a.runStage(sdk.StageAfterToolcall, stageCtx)
|
||||
if len(stageCtx.ToolResults) > 0 {
|
||||
if r, ok := stageCtx.ToolResults[0].Result.(string); ok {
|
||||
result = r
|
||||
}
|
||||
}
|
||||
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
|
||||
|
||||
a.publishEvent(events.EventToolCall, map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"args": tc.Arguments,
|
||||
"result": result,
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return "", toolsUsed, fmt.Errorf("tool execution exceeded %d turns", a.maxTurns)
|
||||
}
|
||||
|
||||
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
|
||||
if tcs == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]sdk.ToolCall, len(tcs))
|
||||
for i, tc := range tcs {
|
||||
result[i] = sdk.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall {
|
||||
if tcs == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]agentAPI.ToolCall, len(tcs))
|
||||
for i, tc := range tcs {
|
||||
result[i] = agentAPI.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Agent) docStoreSize() int {
|
||||
if a.docStore == nil {
|
||||
return 0
|
||||
@ -322,6 +449,15 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
||||
return a.executeOutputListChannels()
|
||||
case tc.Name == "plgreload":
|
||||
return a.executePluginReload()
|
||||
case tc.Name == "spawn_child":
|
||||
return a.executeSpawnChild(tc)
|
||||
}
|
||||
|
||||
// 插件工具(通过 SDK RegisterTool 注册)
|
||||
if a.stageHost != nil {
|
||||
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
|
||||
return fmt.Sprintf("%v", result)
|
||||
}
|
||||
}
|
||||
|
||||
if a.tracker != nil {
|
||||
@ -414,7 +550,19 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
return fmt.Sprintf("记忆统计: %v", stats)
|
||||
|
||||
case "memory_document_query":
|
||||
return a.executeDocTool(tc)
|
||||
return a.executeDocTool(tc)
|
||||
|
||||
case "memory_merge":
|
||||
source, _ := tc.Arguments["source"].(string)
|
||||
target, _ := tc.Arguments["target"].(string)
|
||||
if source == "" || target == "" {
|
||||
return "source 和 target 不能为空"
|
||||
}
|
||||
count, err := a.memory.MergeEntities(source, target)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("合并失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已将「%s」合并到「%s」,%d 条关系已重定向", source, target, count)
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("未知的记忆工具: %s", tc.Name)
|
||||
@ -600,12 +748,45 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// 插件注册的工具(通过 SDK RegisterTool)
|
||||
if a.stageHost != nil {
|
||||
for _, td := range a.stageHost.GetToolDefs() {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": td.Name,
|
||||
"description": td.Description,
|
||||
"parameters": td.Parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if a.indexer != nil {
|
||||
for _, td := range a.indexer.GetToolDefinitions() {
|
||||
tools = append(tools, td)
|
||||
}
|
||||
}
|
||||
|
||||
// 实体合并工具(心跳检测到冲突时 LLM 使用)
|
||||
if a.memory != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_merge",
|
||||
"description": "合并两个同义实体:将所有关系从 source 重定向到 target,source 标记为 merged。仅在有明确证据时使用。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
|
||||
},
|
||||
"required": []string{"source", "target"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 知识库工具
|
||||
if a.knowledge != nil {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
@ -709,6 +890,25 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
})
|
||||
}
|
||||
|
||||
// 子任务工具
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "spawn_child",
|
||||
"description": "创建一个子 Agent 执行独立任务。子 Agent 使用传统上下文(无持久记忆),任务完成即销毁。适用于需要多步推理但不需要写入长期记忆的场景,例如:计算、分析、生成报告草稿等。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要子 Agent 完成的任务描述。请描述清晰、完整,包含所有必要背景。",
|
||||
},
|
||||
},
|
||||
"required": []string{"task"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 输出通道工具
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
@ -764,6 +964,20 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
return tools
|
||||
}
|
||||
|
||||
// ConsolidationTask 心跳检测到的记忆整理任务,通过 IO 发送给 Agent 让 LLM 决策
|
||||
type ConsolidationTask struct {
|
||||
Type string `json:"type"` // "entity_merge", "relation_conflict", "doc_archival"
|
||||
Reason string `json:"reason"` // 人类可读的描述
|
||||
Data interface{} `json:"data"` // 任务相关数据
|
||||
}
|
||||
|
||||
// enqueueConsolidationTask 将记忆整理任务注入到 Agent 输入队列
|
||||
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
|
||||
msg := fmt.Sprintf("【记忆整理任务】\n类型: %s\n说明: %s", task.Type, task.Reason)
|
||||
a.io.InjectTextTo("system", "_consolidation_", msg)
|
||||
log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
|
||||
}
|
||||
|
||||
// distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整
|
||||
func (a *Agent) distillLoop() {
|
||||
if a.docStore == nil && a.memory == nil {
|
||||
@ -896,34 +1110,85 @@ func (a *Agent) reorgGraph() {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 实体向量同义合并
|
||||
// 4. 实体同义冲突检测 → 交由 LLM 决策
|
||||
result, err := a.memory.Recall(nil, nil, 1, "")
|
||||
if err != nil || result == nil || len(result.Entities) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
merged := 0
|
||||
candidates := 0
|
||||
for i := 0; i < len(result.Entities); i++ {
|
||||
for j := i + 1; j < len(result.Entities); j++ {
|
||||
if isSimilarName(result.Entities[i].Name, result.Entities[j].Name) {
|
||||
if result.Entities[i].MentionCount >= result.Entities[j].MentionCount {
|
||||
log.Printf("[agent] reorg: merging '%s' → '%s'", result.Entities[j].Name, result.Entities[i].Name)
|
||||
} else {
|
||||
log.Printf("[agent] reorg: merging '%s' → '%s'", result.Entities[i].Name, result.Entities[j].Name)
|
||||
}
|
||||
merged++
|
||||
sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
|
||||
if sim > 0.5 {
|
||||
candidates++
|
||||
a.enqueueConsolidationTask(ConsolidationTask{
|
||||
Type: "entity_merge",
|
||||
Reason: fmt.Sprintf(
|
||||
"实体「%s」(类型:%s, 提及%d次) 与「%s」(类型:%s, 提及%d次) 相似度 %.0f%%,可能指代同一事物,请判断是否需要合并",
|
||||
result.Entities[i].Name, result.Entities[i].Type, result.Entities[i].MentionCount,
|
||||
result.Entities[j].Name, result.Entities[j].Type, result.Entities[j].MentionCount,
|
||||
sim*100,
|
||||
),
|
||||
Data: map[string]interface{}{
|
||||
"entity_a": result.Entities[i].Name,
|
||||
"entity_a_type": result.Entities[i].Type,
|
||||
"entity_a_mentions": result.Entities[i].MentionCount,
|
||||
"entity_b": result.Entities[j].Name,
|
||||
"entity_b_type": result.Entities[j].Type,
|
||||
"entity_b_mentions": result.Entities[j].MentionCount,
|
||||
"similarity": sim,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if merged > 0 {
|
||||
log.Printf("[agent] graph reorg: merged %d similar entities", merged)
|
||||
if candidates > 0 {
|
||||
log.Printf("[agent] graph reorg: %d merge candidates sent for LLM decision", candidates)
|
||||
} else {
|
||||
log.Printf("[agent] graph reorg: no merges needed")
|
||||
log.Printf("[agent] graph reorg: no similar entities found")
|
||||
}
|
||||
}
|
||||
|
||||
// isSimilarName — 使用字符 bigram Jaccard 相似度判断实体名是否同义
|
||||
// entitySimilarity 计算两个实体名的相似度(字符 bigram Jaccard)
|
||||
func entitySimilarity(a, b string) float64 {
|
||||
if a == "" || b == "" {
|
||||
return 0
|
||||
}
|
||||
if a == b {
|
||||
return 1.0
|
||||
}
|
||||
runesA, runesB := []rune(a), []rune(b)
|
||||
if len(runesA) < 2 || len(runesB) < 2 {
|
||||
if len(runesA) == len(runesB) && len(runesA) == 1 {
|
||||
if runesA[0] == runesB[0] {
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
setA := make(map[string]bool)
|
||||
for i := 0; i < len(runesA)-1; i++ {
|
||||
setA[string(runesA[i:i+2])] = true
|
||||
}
|
||||
|
||||
intersect := 0
|
||||
for i := 0; i < len(runesB)-1; i++ {
|
||||
if setA[string(runesB[i:i+2])] {
|
||||
intersect++
|
||||
}
|
||||
}
|
||||
|
||||
union := len(setA) + len(runesB) - 1 - intersect
|
||||
if union <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return float64(intersect) / float64(union)
|
||||
}
|
||||
|
||||
// docToTriples 将文档转为图记忆三元组
|
||||
func docToTriples(doc *document.Doc) []memory.Triple {
|
||||
var triples []memory.Triple
|
||||
@ -964,36 +1229,6 @@ func docToTriples(doc *document.Doc) []memory.Triple {
|
||||
return triples
|
||||
}
|
||||
|
||||
func isSimilarName(a, b string) bool {
|
||||
if a == b {
|
||||
return false // 自带跳过
|
||||
}
|
||||
runesA, runesB := []rune(a), []rune(b)
|
||||
if len(runesA) < 2 || len(runesB) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
setA := make(map[string]bool)
|
||||
for i := 0; i < len(runesA)-1; i++ {
|
||||
setA[string(runesA[i:i+2])] = true
|
||||
}
|
||||
|
||||
intersect := 0
|
||||
for i := 0; i < len(runesB)-1; i++ {
|
||||
if setA[string(runesB[i:i+2])] {
|
||||
intersect++
|
||||
}
|
||||
}
|
||||
|
||||
union := len(setA) + len(runesB) - 1 - intersect
|
||||
if union <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
jaccard := float64(intersect) / float64(union)
|
||||
return jaccard > 0.5
|
||||
}
|
||||
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []string) {
|
||||
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
|
||||
"source": source,
|
||||
@ -1007,6 +1242,33 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []
|
||||
|
||||
// executeOutputChannelTool — AI 切换当前请求的输出通道
|
||||
// 在 process() 内调用,mutex 保护,只有一个请求在执行
|
||||
// processConsolidation 处理后台记忆整理任务(不发外部输出)
|
||||
func (a *Agent) processConsolidation(input string) {
|
||||
start := time.Now()
|
||||
a.currentOutputChannel = "_consolidation_"
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: start,
|
||||
Source: "system",
|
||||
Input: input,
|
||||
})
|
||||
response, toolsUsed, err := a.process(input, &sdk.StageContext{RawMessage: input})
|
||||
if err != nil {
|
||||
log.Printf("[agent] consolidation error: %v", err)
|
||||
return
|
||||
}
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
})
|
||||
_ = a.context.Prune(response, a.maxContextSize, a.docStore)
|
||||
// 只写入记忆,不发外部输出
|
||||
a.emitMemoryCandidate("system", input, response, toolsUsed)
|
||||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||||
}
|
||||
|
||||
func (a *Agent) executeOutputChannelTool(tc agentAPI.ToolCall) string {
|
||||
channel, _ := tc.Arguments["channel"].(string)
|
||||
if channel == "" {
|
||||
@ -1077,6 +1339,118 @@ func (a *Agent) executePluginReload() string {
|
||||
return msg
|
||||
}
|
||||
|
||||
// executeSpawnChild 创建子 Agent 执行独立任务
|
||||
// 子 Agent 使用传统上下文(单轮对话),无持久记忆,任务完即销毁
|
||||
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
|
||||
task, _ := tc.Arguments["task"].(string)
|
||||
if task == "" {
|
||||
return "请提供 task 参数"
|
||||
}
|
||||
|
||||
sysPrompt := fmt.Sprintf(`你是 HomeAgent 的子任务助手。
|
||||
请完成以下任务。完成即可,无需保留记忆或查询历史。
|
||||
任务: %s`, task)
|
||||
|
||||
msgs := []agentAPI.Message{
|
||||
{Role: "system", Content: sysPrompt},
|
||||
{Role: "user", Content: task},
|
||||
}
|
||||
|
||||
// 子 Agent 无特殊工具,只保留基础 tool 定义(无记忆/知识/文档工具)
|
||||
childTools := []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_send",
|
||||
"description": "通过指定输出通道发送消息",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"channel": map[string]interface{}{"type": "string", "description": "输出通道"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "消息内容"},
|
||||
},
|
||||
"required": []string{"channel", "content"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for turn := 0; turn < 5; turn++ {
|
||||
req := &agentAPI.CompletionRequest{
|
||||
Messages: msgs,
|
||||
MaxTokens: 4096,
|
||||
Tools: childTools,
|
||||
ToolChoice: "auto",
|
||||
ExtraBody: map[string]interface{}{
|
||||
"thinking": map[string]interface{}{"type": "disabled"},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := a.provider.Chat(a.ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("子 Agent 执行失败: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content
|
||||
}
|
||||
|
||||
for _, ct := range resp.ToolCalls {
|
||||
var result string
|
||||
if ct.Name == "output_send" {
|
||||
channel, _ := ct.Arguments["channel"].(string)
|
||||
content, _ := ct.Arguments["content"].(string)
|
||||
if channel != "" && content != "" {
|
||||
a.io.EmitTextTo("child_agent", channel, content)
|
||||
result = fmt.Sprintf("已通过 [%s] 通道发送", channel)
|
||||
} else {
|
||||
result = "channel 和 content 不能为空"
|
||||
}
|
||||
} else {
|
||||
result = fmt.Sprintf("子 Agent 无法调用工具 %s", ct.Name)
|
||||
}
|
||||
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
|
||||
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
|
||||
}
|
||||
}
|
||||
|
||||
return "子 Agent 执行超时(超过 5 轮)"
|
||||
}
|
||||
|
||||
// runStage — 运行阶段管道,若插件 Response 被设置则返回 true(短路)
|
||||
func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
|
||||
if a.stageHost == nil {
|
||||
return false
|
||||
}
|
||||
ctx.Phase = stage
|
||||
a.stageHost.RunStage(stage, ctx)
|
||||
return ctx.Response != nil
|
||||
}
|
||||
|
||||
// publishEvent — 发布系统事件
|
||||
func (a *Agent) publishEvent(evtType events.EventType, payload map[string]interface{}) {
|
||||
if a.eventBus == nil {
|
||||
return
|
||||
}
|
||||
a.eventBus.Publish(&events.Event{
|
||||
Type: evtType,
|
||||
Source: string(a.id),
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// stageCtxFromInput — 根据输入构建阶段上下文
|
||||
func (a *Agent) stageCtxFromInput(input, userID, groupID string) *sdk.StageContext {
|
||||
return &sdk.StageContext{
|
||||
RawMessage: input,
|
||||
UserID: userID,
|
||||
GroupID: groupID,
|
||||
Phase: sdk.StageOnInput,
|
||||
Extra: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func getFloat(m map[string]interface{}, key string) float64 {
|
||||
if v, ok := m[key]; ok {
|
||||
switch n := v.(type) {
|
||||
|
||||
@ -6,21 +6,21 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
func TestIsSimilarName(t *testing.T) {
|
||||
func TestEntitySimilarity(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
want float64
|
||||
}{
|
||||
{"张三", "张三四", false},
|
||||
{"", "", false},
|
||||
{"a", "b", false},
|
||||
{"张三", "李四", false},
|
||||
{"张三", "张三", false},
|
||||
{"", "", 0}, // empty → 0
|
||||
{"a", "b", 0}, // single char → 0
|
||||
{"张三", "张三", 1.0}, // identical → 1.0
|
||||
{"张三", "李四", 0}, // no common bigrams
|
||||
{"iPhone", "iPhone 15", 0.625}, // partial overlap
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isSimilarName(tt.a, tt.b)
|
||||
got := entitySimilarity(tt.a, tt.b)
|
||||
if got != tt.want {
|
||||
t.Errorf("isSimilarName(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
t.Errorf("entitySimilarity(%q, %q) = %.3f, want %.3f", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -13,26 +16,63 @@ import (
|
||||
|
||||
// ContextEvent — 单条上下文事件
|
||||
type ContextEvent struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Input string `json:"input"`
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
Vector vector.Vector `json:"-"` // 缓存向量,避免重复计算
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Input string `json:"input"`
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
Vector vector.Vector `json:"-"` // 缓存向量,避免重复计算
|
||||
}
|
||||
|
||||
// RelevanceContext — 基于相关性的上下文管理,非固定阈值
|
||||
type RelevanceContext struct {
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
veczer *vector.TFIDFVectorizer
|
||||
trained bool
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
veczer *vector.TFIDFVectorizer
|
||||
trained bool
|
||||
savePath string // 持久化路径,空则不持久化
|
||||
}
|
||||
|
||||
func NewRelevanceContext() *RelevanceContext {
|
||||
return &RelevanceContext{
|
||||
veczer: vector.NewTFIDFVectorizer(2),
|
||||
func NewRelevanceContext(savePath string) *RelevanceContext {
|
||||
rc := &RelevanceContext{
|
||||
veczer: vector.NewTFIDFVectorizer(2),
|
||||
savePath: savePath,
|
||||
}
|
||||
if savePath != "" {
|
||||
rc.load()
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
// load 从文件恢复上下文事件
|
||||
func (c *RelevanceContext) load() {
|
||||
data, err := os.ReadFile(c.savePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var events []*ContextEvent
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return
|
||||
}
|
||||
for _, evt := range events {
|
||||
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
|
||||
}
|
||||
c.events = events
|
||||
}
|
||||
|
||||
// Save 持久化上下文事件到文件
|
||||
func (c *RelevanceContext) Save() error {
|
||||
if c.savePath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(c.savePath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(c.events)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.savePath, data, 0644)
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
@ -44,6 +84,20 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
|
||||
// 增量训练向量化器
|
||||
c.trained = false
|
||||
|
||||
c.save()
|
||||
}
|
||||
|
||||
// save 无锁版本,Append/Prune 内部持有锁时调用
|
||||
func (c *RelevanceContext) save() error {
|
||||
if c.savePath == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(c.events)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.savePath, data, 0644)
|
||||
}
|
||||
|
||||
// Prune — 基于当前输入计算每条上下文的相关性,归档最不相关的
|
||||
@ -114,6 +168,8 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
}
|
||||
}
|
||||
|
||||
c.save()
|
||||
|
||||
return archived
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestContextAppendAndLen(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
ctx := NewRelevanceContext("")
|
||||
if ctx.Len() != 0 {
|
||||
t.Errorf("new context should be empty, got %d", ctx.Len())
|
||||
}
|
||||
@ -18,7 +18,7 @@ func TestContextAppendAndLen(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextRecent(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
ctx := NewRelevanceContext("")
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "a"})
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "b"})
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "c"})
|
||||
@ -33,7 +33,7 @@ func TestContextRecent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextFormat(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
ctx := NewRelevanceContext("")
|
||||
f := ctx.Format()
|
||||
if f != "" {
|
||||
t.Errorf("empty context should format to empty string, got %q", f)
|
||||
@ -54,7 +54,7 @@ func TestContextFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextPruneKeepsTopK(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
ctx := NewRelevanceContext("")
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
@ -80,7 +80,7 @@ func TestContextPruneKeepsTopK(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextPruneWithDocStore(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
ctx := NewRelevanceContext("")
|
||||
for i := 0; i < 15; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
@ -97,7 +97,7 @@ func TestContextPruneWithDocStore(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextAppendAfterPrune(t *testing.T) {
|
||||
ctx := NewRelevanceContext()
|
||||
ctx := NewRelevanceContext("")
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
|
||||
74
internal/agent/core/stages.go
Normal file
74
internal/agent/core/stages.go
Normal file
@ -0,0 +1,74 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
||||
)
|
||||
|
||||
type StageHost struct {
|
||||
plugins []*sdk.PluginAPI
|
||||
toolDefs []sdk.ToolDef
|
||||
tools map[string]sdk.ToolHandler
|
||||
}
|
||||
|
||||
func NewStageHost() *StageHost {
|
||||
return &StageHost{
|
||||
tools: make(map[string]sdk.ToolHandler),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StageHost) RegisterPlugin(api *sdk.PluginAPI) {
|
||||
h.plugins = append(h.plugins, api)
|
||||
for name, handler := range api.Tools() {
|
||||
h.tools[name] = handler
|
||||
h.toolDefs = append(h.toolDefs, sdk.ToolDef{Name: name})
|
||||
}
|
||||
}
|
||||
|
||||
// SyncFromRegistry 从插件注册表同步 SDK 插件
|
||||
func (h *StageHost) SyncFromRegistry(reg *plugin.Registry) {
|
||||
if reg == nil {
|
||||
return
|
||||
}
|
||||
for _, td := range reg.GetAllSDKToolDefs() {
|
||||
h.toolDefs = append(h.toolDefs, td)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StageHost) GetToolDefs() []sdk.ToolDef {
|
||||
return h.toolDefs
|
||||
}
|
||||
|
||||
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
if handler, ok := h.tools[name]; ok {
|
||||
return handler(args)
|
||||
}
|
||||
return nil, fmt.Errorf("tool %s not found in any plugin", name)
|
||||
}
|
||||
|
||||
func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
for _, p := range h.plugins {
|
||||
for _, handler := range p.StageHandlers(stage) {
|
||||
if err := handler(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
if ctx.Response != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StageHost) RunStageAll(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
for _, p := range h.plugins {
|
||||
for _, handler := range p.StageHandlers(stage) {
|
||||
handler(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StageHost) PluginCount() int {
|
||||
return len(h.plugins)
|
||||
}
|
||||
183
internal/agent/core/stages_test.go
Normal file
183
internal/agent/core/stages_test.go
Normal file
@ -0,0 +1,183 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
|
||||
)
|
||||
|
||||
func TestStageHostRegisterPlugin(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
||||
|
||||
api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
|
||||
host.RegisterPlugin(api)
|
||||
|
||||
if host.PluginCount() != 1 {
|
||||
t.Errorf("expected 1 plugin, got %d", host.PluginCount())
|
||||
}
|
||||
|
||||
defs := host.GetToolDefs()
|
||||
if len(defs) != 1 {
|
||||
t.Errorf("expected 1 tool def, got %d", len(defs))
|
||||
}
|
||||
if defs[0].Name != "test_tool" {
|
||||
t.Errorf("expected test_tool, got %s", defs[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostExecuteTool(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
||||
|
||||
api.RegisterTool("hello", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "world", nil
|
||||
})
|
||||
|
||||
host.RegisterPlugin(api)
|
||||
|
||||
result, err := host.ExecuteTool("hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if result.(string) != "world" {
|
||||
t.Errorf("expected world, got %v", result)
|
||||
}
|
||||
|
||||
_, err = host.ExecuteTool("nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRunStage(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
||||
|
||||
var called bool
|
||||
api.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
host.RegisterPlugin(api)
|
||||
|
||||
ctx := &sdk.StageContext{RawMessage: "hello"}
|
||||
host.RunStage(sdk.StageOnInput, ctx)
|
||||
|
||||
if !called {
|
||||
t.Error("stage handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRunStageShortCircuit(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
|
||||
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
|
||||
api1.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
resp := "short-circuited"
|
||||
ctx.Response = &resp
|
||||
return nil
|
||||
})
|
||||
|
||||
var api2called bool
|
||||
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
|
||||
api2.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||
api2called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
host.RegisterPlugin(api1)
|
||||
host.RegisterPlugin(api2)
|
||||
|
||||
ctx := &sdk.StageContext{RawMessage: "hello"}
|
||||
host.RunStage(sdk.StageOnInput, ctx)
|
||||
|
||||
if ctx.Response == nil || *ctx.Response != "short-circuited" {
|
||||
t.Errorf("expected short-circuited, got %v", ctx.Response)
|
||||
}
|
||||
if api2called {
|
||||
t.Error("api2 should not have been called after short circuit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostRunStageAll(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
|
||||
count := 0
|
||||
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
|
||||
api1.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
|
||||
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
|
||||
api2.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
|
||||
host.RegisterPlugin(api1)
|
||||
host.RegisterPlugin(api2)
|
||||
|
||||
host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 handlers called, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostMultiplePlugins(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
|
||||
p1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
|
||||
p1.RegisterTool("tool1", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "from_p1", nil
|
||||
})
|
||||
|
||||
p2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
|
||||
p2.RegisterTool("tool2", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "from_p2", nil
|
||||
})
|
||||
|
||||
host.RegisterPlugin(p1)
|
||||
host.RegisterPlugin(p2)
|
||||
|
||||
if host.PluginCount() != 2 {
|
||||
t.Errorf("expected 2 plugins, got %d", host.PluginCount())
|
||||
}
|
||||
|
||||
r1, _ := host.ExecuteTool("tool1", nil)
|
||||
if r1.(string) != "from_p1" {
|
||||
t.Errorf("expected from_p1, got %v", r1)
|
||||
}
|
||||
|
||||
r2, _ := host.ExecuteTool("tool2", nil)
|
||||
if r2.(string) != "from_p2" {
|
||||
t.Errorf("expected from_p2, got %v", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageHostEmpty(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
|
||||
if host.PluginCount() != 0 {
|
||||
t.Errorf("expected 0 plugins, got %d", host.PluginCount())
|
||||
}
|
||||
|
||||
defs := host.GetToolDefs()
|
||||
if len(defs) != 0 {
|
||||
t.Errorf("expected 0 tool defs, got %d", len(defs))
|
||||
}
|
||||
|
||||
_, err := host.ExecuteTool("anything", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error on empty host")
|
||||
}
|
||||
|
||||
// RunStage on empty host should not panic
|
||||
host.RunStage(sdk.StageOnInput, &sdk.StageContext{})
|
||||
}
|
||||
Reference in New Issue
Block a user