mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
refactor: P0-P3 fixes, C1 cleanup, architecture diagrams, go.work upgrade
- P0-1: ProviderError type + ReportStatus for precise 401/403 detection - P0-2: Remove -config flag from deploy/homeagent.service - P2-1: 5s debounce on context.go Save() - P2-2→C1: Delete output_set_channel entirely - P2-3: Extract mediaDataURL/mediaChat helpers - P2-4: Dedup defaultSources var - P3: Delete dead packages (embed/tokenizer/container/snapshot) - P3: Delete dead functions (messagesToMap, RunStageAll) - CL: Update .gitignore, docs, Makefile, gojieba removal - Config: Delete config/config.yaml, update docs - Arch: Remove EmitOutputTo from emitResponse - CL-1: go.work 1.19→1.21 - Docs: Add Mermaid architecture diagrams to README - Docs: Add kernel-rebuild requires plugin-rebuild note to PLUGIN_DEV.md
This commit is contained in:
@ -197,7 +197,10 @@ func (p *OpenAIProvider) Chat(ctx context.Context, req *CompletionRequest) (*Com
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(respBody))
|
||||
return nil, &ProviderError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("api error %d: %s", resp.StatusCode, string(respBody)),
|
||||
}
|
||||
}
|
||||
|
||||
var rawResult struct {
|
||||
@ -445,7 +448,10 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(rawResp))
|
||||
return nil, &ProviderError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: fmt.Sprintf("api error %d: %s", resp.StatusCode, string(rawResp)),
|
||||
}
|
||||
}
|
||||
|
||||
unifiedJSON, err := p.vm.CallTransformResponse(p.adapter, string(rawResp))
|
||||
@ -578,16 +584,28 @@ func (s *SSEScanner) Scan() bool {
|
||||
|
||||
func (s *SSEScanner) Text() string { return s.pending }
|
||||
|
||||
type providerStatus struct {
|
||||
failCount int
|
||||
unavailableUntil time.Time
|
||||
}
|
||||
|
||||
type ProviderManager struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]Provider
|
||||
order []string
|
||||
default_ string
|
||||
status map[string]*providerStatus
|
||||
}
|
||||
|
||||
const (
|
||||
providerCooldownBase = 30 * time.Second
|
||||
providerCooldownMax = 30 * time.Minute
|
||||
)
|
||||
|
||||
func NewProviderManager() *ProviderManager {
|
||||
return &ProviderManager{
|
||||
providers: make(map[string]Provider),
|
||||
status: make(map[string]*providerStatus),
|
||||
}
|
||||
}
|
||||
|
||||
@ -650,11 +668,58 @@ func (m *ProviderManager) List() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *ProviderManager) MarkUnavailable(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
st := m.status[name]
|
||||
if st == nil {
|
||||
st = &providerStatus{}
|
||||
m.status[name] = st
|
||||
}
|
||||
st.failCount++
|
||||
cooldown := providerCooldownBase * time.Duration(1<<(st.failCount-1))
|
||||
if cooldown > providerCooldownMax {
|
||||
cooldown = providerCooldownMax
|
||||
}
|
||||
st.unavailableUntil = time.Now().Add(cooldown)
|
||||
}
|
||||
|
||||
// ReportStatus records an HTTP status code for a provider, allowing auth errors
|
||||
// (401/403) to be distinguished from transient failures.
|
||||
func (m *ProviderManager) ReportStatus(name string, statusCode int) {
|
||||
if statusCode == 401 || statusCode == 403 {
|
||||
m.MarkUnavailable(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ProviderManager) ResetAvailability(name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.status, name)
|
||||
}
|
||||
|
||||
func (m *ProviderManager) IsAvailable(name string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
st, ok := m.status[name]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return time.Now().After(st.unavailableUntil)
|
||||
}
|
||||
|
||||
func (m *ProviderManager) OrderedProviders() []Provider {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
list := make([]Provider, 0, len(m.order))
|
||||
// 把默认 provider 放第一位,其余按注册顺序
|
||||
if def, ok := m.providers[m.default_]; ok {
|
||||
list = append(list, def)
|
||||
}
|
||||
for _, name := range m.order {
|
||||
if name == m.default_ {
|
||||
continue
|
||||
}
|
||||
if p, ok := m.providers[name]; ok {
|
||||
list = append(list, p)
|
||||
}
|
||||
@ -677,15 +742,14 @@ type rawToolCall struct {
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
func messagesToMap(msgs []Message) []interface{} {
|
||||
result := make([]interface{}, len(msgs))
|
||||
for i, m := range msgs {
|
||||
result[i] = map[string]interface{}{
|
||||
"role": m.Role,
|
||||
"content": m.Content,
|
||||
}
|
||||
}
|
||||
return result
|
||||
// ProviderError wraps an HTTP-level error with status code for precise auth detection.
|
||||
type ProviderError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
|
||||
@ -2,9 +2,11 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -102,6 +104,26 @@ type Agent struct {
|
||||
|
||||
// 非文本输入处理配置
|
||||
inputCfg types.InputProcessingConfig
|
||||
|
||||
// noMergeMarkets 记录被标记"禁止合并"的实体对,key="entityA||entityB"(字典序),
|
||||
// 每次 reorgGraph 扫描到对应实体对时计数减一,归零后自动移除。
|
||||
noMergeMarkers map[string]int
|
||||
noMergeMu sync.Mutex
|
||||
|
||||
// toolCallRing 保护最近 40 条工具调用记录不被上下文淘汰,
|
||||
// 确保 LLM 不会重复调用同一工具、反复查询同一数据。
|
||||
toolCallRing []ToolCallRecord
|
||||
toolCallRingMax int
|
||||
toolCallRingMu sync.Mutex // 独立的锁,不与 a.mu 混用避免死锁
|
||||
}
|
||||
|
||||
// ToolCallRecord 记录一次工具调用,保留元数据供后续 LLM 回合参考。
|
||||
type ToolCallRecord struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args,omitempty"` // 参数摘要(最多 200 字符)
|
||||
ResultStub string `json:"result_stub"` // 结果摘要(具体内容通过文本记忆层获取)
|
||||
FullResult string `json:"result_full,omitempty"` // 完整结果(仅保留最近 5 条,其余仅存 stub)
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
@ -169,7 +191,10 @@ func New(cfg AgentConfig) *Agent {
|
||||
childResults: make(map[string]string),
|
||||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
toolCallRing: make([]ToolCallRecord, 0, 40),
|
||||
toolCallRingMax: 40,
|
||||
}
|
||||
}
|
||||
|
||||
@ -527,7 +552,7 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
a.runStage(sdk.StageBeforeOutput, stageCtx)
|
||||
response = stageCtx.FinalText
|
||||
|
||||
// 读取当前输出通道(可能已被 AI 通过 output_set_channel 切换)
|
||||
// 读取当前输出通道(来源:输入事件自带的 OutputChannel)
|
||||
ch := a.currentOutputChannel
|
||||
if ch == "" {
|
||||
ch = evt.OutputChannel
|
||||
@ -547,8 +572,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
payload["usage"] = stageCtx.TokenUsage
|
||||
}
|
||||
|
||||
a.io.EmitOutputTo(evt.Source, ch, "text", payload)
|
||||
|
||||
if evt.ResponseCh != nil {
|
||||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||||
RequestID: evt.RequestID,
|
||||
@ -634,9 +657,16 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
|
||||
// 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求
|
||||
// 多 LLM 源顺位降级:当当前 provider 失败时,按注册顺序依次尝试
|
||||
// 带断路器:401/403 自动标记不可用,连续失败指数退避
|
||||
var providers []agentAPI.Provider
|
||||
if a.providerManager != nil {
|
||||
providers = a.providerManager.OrderedProviders()
|
||||
allProviders := a.providerManager.OrderedProviders()
|
||||
providers = make([]agentAPI.Provider, 0, len(allProviders))
|
||||
for _, p := range allProviders {
|
||||
if a.providerManager.IsAvailable(p.Name()) {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
providers = []agentAPI.Provider{a.provider}
|
||||
@ -663,6 +693,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
fCancel()
|
||||
|
||||
if llmErr == nil {
|
||||
a.providerManager.ResetAvailability(fbProvider.Name())
|
||||
if fbProvider != a.provider {
|
||||
a.provider = fbProvider
|
||||
log.Printf("[agent] switched active provider to %q after fallback",
|
||||
@ -670,6 +701,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
var pe *agentAPI.ProviderError
|
||||
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
|
||||
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
|
||||
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
|
||||
} else {
|
||||
a.providerManager.MarkUnavailable(fbProvider.Name())
|
||||
}
|
||||
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
||||
}
|
||||
|
||||
@ -746,6 +785,10 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
}
|
||||
|
||||
// 记录到工具调用环缓冲区(保护最近 40 条)
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
a.recordToolCall(tc.Name, string(argsJSON), result)
|
||||
|
||||
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})
|
||||
|
||||
@ -795,11 +838,133 @@ func (a *Agent) docStoreSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// recordToolCall 在工具执行后记录到环缓冲区,保留最近 40 条,
|
||||
// 确保 LLM 在后续回合中能感知到已执行过的工具及其结果。
|
||||
func (a *Agent) recordToolCall(name, args, result string) {
|
||||
a.toolCallRingMu.Lock()
|
||||
defer a.toolCallRingMu.Unlock()
|
||||
|
||||
// 参数截断
|
||||
if len(args) > 200 {
|
||||
args = args[:200] + "..."
|
||||
}
|
||||
|
||||
var resultStub string
|
||||
var fullResult string
|
||||
if len(a.toolCallRing) < 5 {
|
||||
// 最近 5 条保留完整结果
|
||||
fullResult = result
|
||||
}
|
||||
if len(result) > 80 {
|
||||
resultStub = result[:80] + "..."
|
||||
} else {
|
||||
resultStub = result
|
||||
}
|
||||
|
||||
rec := ToolCallRecord{
|
||||
Timestamp: time.Now(),
|
||||
Name: name,
|
||||
Args: args,
|
||||
ResultStub: resultStub,
|
||||
FullResult: fullResult,
|
||||
}
|
||||
|
||||
if len(a.toolCallRing) >= a.toolCallRingMax {
|
||||
a.toolCallRing = a.toolCallRing[1:]
|
||||
}
|
||||
a.toolCallRing = append(a.toolCallRing, rec)
|
||||
}
|
||||
|
||||
// formatToolCallRing 输出工具调用环缓冲区为可读文本,注入到 system prompt。
|
||||
func (a *Agent) formatToolCallRing() string {
|
||||
a.toolCallRingMu.Lock()
|
||||
defer a.toolCallRingMu.Unlock()
|
||||
|
||||
if len(a.toolCallRing) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("【已执行工具记录(最近40条)】\n")
|
||||
start := 0
|
||||
if len(a.toolCallRing) > 40 {
|
||||
start = len(a.toolCallRing) - 40
|
||||
}
|
||||
for i, rec := range a.toolCallRing[start:] {
|
||||
if len(rec.FullResult) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s)=%s\n", i+1,
|
||||
rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args,
|
||||
truncateStr(rec.FullResult, 120)))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s) → (已缓存,具体结果通过文本记忆层获取)\n", i+1,
|
||||
rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatMergedTimeline 合并上下文事件和工具调用记录为一条按时间排序的对话时序,
|
||||
// 替代原先分块注入(【近期事件】+【已执行工具记录】)的方式。
|
||||
func (a *Agent) formatMergedTimeline() string {
|
||||
a.context.mu.Lock()
|
||||
events := make([]*ContextEvent, len(a.context.events))
|
||||
copy(events, a.context.events)
|
||||
a.context.mu.Unlock()
|
||||
|
||||
a.toolCallRingMu.Lock()
|
||||
ring := make([]ToolCallRecord, len(a.toolCallRing))
|
||||
copy(ring, a.toolCallRing)
|
||||
a.toolCallRingMu.Unlock()
|
||||
|
||||
if len(events) == 0 && len(ring) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
type timelineEntry struct {
|
||||
ts time.Time
|
||||
label string
|
||||
text string
|
||||
}
|
||||
entries := make([]timelineEntry, 0, len(events)+len(ring))
|
||||
|
||||
for _, e := range events {
|
||||
text := fmt.Sprintf("[对话] %s: %s", e.Source, e.Input)
|
||||
if len(e.ToolsUsed) > 0 {
|
||||
text += fmt.Sprintf(" → 调用工具: %s", strings.Join(e.ToolsUsed, ", "))
|
||||
}
|
||||
if e.Response != "" {
|
||||
text += fmt.Sprintf(" → %s", truncateStr(e.Response, 120))
|
||||
}
|
||||
entries = append(entries, timelineEntry{ts: e.Timestamp, label: "对话", text: text})
|
||||
}
|
||||
|
||||
for _, r := range ring {
|
||||
text := fmt.Sprintf("[工具] %s(%s)", r.Name, r.Args)
|
||||
if r.FullResult != "" {
|
||||
text += fmt.Sprintf(" = %s", truncateStr(r.FullResult, 120))
|
||||
} else {
|
||||
text += " → (结果已缓存,可通过文本记忆层获取)"
|
||||
}
|
||||
entries = append(entries, timelineEntry{ts: r.Timestamp, label: "工具", text: text})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].ts.Before(entries[j].ts)
|
||||
})
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("【对话时序】\n")
|
||||
for _, e := range entries {
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s\n", e.ts.Format("15:04:05"), e.text))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (a *Agent) buildMessages(sysPrompt, input string) []agentAPI.Message {
|
||||
msgs := []agentAPI.Message{{Role: "system", Content: sysPrompt}}
|
||||
|
||||
ctxStr := a.context.Format()
|
||||
if ctxStr != "" {
|
||||
// 合并上下文事件 + 工具调用记录为一条完整时序
|
||||
if ctxStr := a.formatMergedTimeline(); ctxStr != "" {
|
||||
msgs = append(msgs, agentAPI.Message{Role: "system", Content: ctxStr})
|
||||
}
|
||||
|
||||
@ -817,8 +982,6 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
||||
return a.executeKnowledgeTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "doc_"):
|
||||
return a.executeDocTool(tc)
|
||||
case tc.Name == "output_set_channel":
|
||||
return a.executeOutputChannelTool(tc)
|
||||
case tc.Name == "output_send":
|
||||
return a.executeOutputSendTool(tc)
|
||||
case tc.Name == "output_list_channels":
|
||||
@ -911,6 +1074,22 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
|
||||
case "memory_block_merge":
|
||||
entityA, _ := tc.Arguments["entity_a"].(string)
|
||||
entityB, _ := tc.Arguments["entity_b"].(string)
|
||||
rounds, _ := tc.Arguments["rounds"].(float64)
|
||||
if entityA == "" || entityB == "" || rounds <= 0 {
|
||||
return "entity_a、entity_b 和 rounds 不能为空"
|
||||
}
|
||||
if entityA > entityB {
|
||||
entityA, entityB = entityB, entityA
|
||||
}
|
||||
key := entityA + "||" + entityB
|
||||
a.noMergeMu.Lock()
|
||||
a.noMergeMarkers[key] = int(rounds)
|
||||
a.noMergeMu.Unlock()
|
||||
return fmt.Sprintf("已标记「%s」与「%s」在 %d 轮内不合并", entityA, entityB, int(rounds))
|
||||
|
||||
case "memory_commit":
|
||||
triplesData, ok := tc.Arguments["triples"].([]interface{})
|
||||
if !ok {
|
||||
@ -958,7 +1137,17 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
if err != nil {
|
||||
return fmt.Sprintf("合并失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已将「%s」合并到「%s」,%d 条关系已重定向", source, target, count)
|
||||
return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
|
||||
|
||||
case "memory_delete_entity":
|
||||
name, _ := tc.Arguments["name"].(string)
|
||||
if name == "" {
|
||||
return "name 不能为空"
|
||||
}
|
||||
if err := a.memory.DeleteEntity(name); err != nil {
|
||||
return fmt.Sprintf("删除失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
|
||||
|
||||
case "memory_purge":
|
||||
criteria := make(map[string]string)
|
||||
@ -1227,13 +1416,23 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
return "未找到相关文档记忆"
|
||||
}
|
||||
var parts []string
|
||||
var refs []string
|
||||
for i, d := range docs {
|
||||
parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
|
||||
if len(d.Tags) > 0 {
|
||||
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
|
||||
}
|
||||
// 每个文档按原始时间写入 context 事件,确保时序正确
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: d.CreatedAt,
|
||||
Source: "cold_storage",
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: d.Content,
|
||||
})
|
||||
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
|
||||
len(docs), strings.Join(refs, ", "))
|
||||
|
||||
case "doc_commit":
|
||||
content, _ := tc.Arguments["content"].(string)
|
||||
@ -1295,6 +1494,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt += "\n\n" + memContext
|
||||
}
|
||||
|
||||
// 记忆清理指令:当用户要求整理或清理记忆时,必须实际调用 memory_ 工具执行操作,
|
||||
// 不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情,
|
||||
// 然后依次调用 memory_merge/memory_purge/memory_edit/memory_block_merge 执行清理。
|
||||
prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
|
||||
|
||||
// 文档记忆 — 查询相关文档摘要注入
|
||||
if a.docStore != nil {
|
||||
docs := a.docStore.Query(userInput, 3)
|
||||
@ -1387,7 +1591,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_merge",
|
||||
"description": "合并两个同义实体:将所有关系从 source 重定向到 target,source 标记为 merged。仅在有明确证据时使用。",
|
||||
"description": "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -1401,13 +1605,43 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_purge",
|
||||
"description": "删除指定条件的记忆关系。支持按主体、客体、关系类型筛选。谨慎使用。",
|
||||
"name": "memory_delete_entity",
|
||||
"description": "【记忆清理】彻底删除指定实体及其所有关联关系。用于清理无用的噪音实体,如 mentionCount=0 的孤立实体、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词"},
|
||||
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_block_merge",
|
||||
"description": "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
|
||||
"entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
|
||||
"rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
|
||||
},
|
||||
"required": []string{"entity_a", "entity_b", "rounds"},
|
||||
},
|
||||
},
|
||||
})
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_purge",
|
||||
"description": "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
|
||||
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
|
||||
"target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
|
||||
},
|
||||
@ -1418,7 +1652,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_edit",
|
||||
"description": "编辑记忆:删除旧的 relation 并写入新的。例如修正错误的实体名或关系类型。",
|
||||
"description": "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -1671,25 +1905,24 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
})
|
||||
}
|
||||
|
||||
// 输出通道工具
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "output_set_channel",
|
||||
"description": "切换当前对话的输出通道。例如从 voice 切换到 email,后续所有回复将通过新通道发送。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"channel": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "输出通道名称: voice (语音), email (邮件), screen (屏幕), http (HTTP)",
|
||||
"enum": []interface{}{"voice", "email", "screen", "http"},
|
||||
},
|
||||
},
|
||||
"required": []string{"channel"},
|
||||
},
|
||||
},
|
||||
})
|
||||
// 输出通道工具 — 从已注册 Device 动态生成
|
||||
channels := a.io.ListChannels()
|
||||
chanNames := make([]interface{}, 0, len(channels))
|
||||
chanDesc := "输出通道名称: "
|
||||
for i, ch := range channels {
|
||||
if ch.Type == agentIO.DeviceOutput || ch.Type == agentIO.DeviceIO {
|
||||
chanNames = append(chanNames, ch.Name)
|
||||
if i > 0 {
|
||||
chanDesc += ", "
|
||||
}
|
||||
chanDesc += ch.Name
|
||||
}
|
||||
}
|
||||
if len(chanNames) == 0 {
|
||||
chanNames = []interface{}{"default"}
|
||||
chanDesc = "输出通道名称: default"
|
||||
}
|
||||
|
||||
tools = append(tools, map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
@ -1711,7 +1944,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"properties": map[string]interface{}{
|
||||
"channel": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "输出通道: voice, email, screen, http",
|
||||
"description": chanDesc,
|
||||
},
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
@ -1794,7 +2027,10 @@ type ConsolidationTask struct {
|
||||
|
||||
// enqueueConsolidationTask 将记忆整理任务通过自循环通道注入 Agent(不经过 IO 层)
|
||||
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
|
||||
msg := fmt.Sprintf("【记忆整理任务】\n类型: %s\n说明: %s", task.Type, task.Reason)
|
||||
msg := fmt.Sprintf(
|
||||
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
|
||||
task.Type, task.Reason,
|
||||
)
|
||||
a.injectSelf(msg)
|
||||
log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
|
||||
}
|
||||
@ -1942,11 +2178,32 @@ func (a *Agent) reorgGraph() {
|
||||
return
|
||||
}
|
||||
|
||||
// 最多处理 5 个候选,避免阻塞用户消息太久
|
||||
maxCandidates := 5
|
||||
candidates := 0
|
||||
for i := 0; i < len(result.Entities); i++ {
|
||||
for j := i + 1; j < len(result.Entities); j++ {
|
||||
for i := 0; i < len(result.Entities) && candidates < maxCandidates; i++ {
|
||||
for j := i + 1; j < len(result.Entities) && candidates < maxCandidates; j++ {
|
||||
ea, eb := result.Entities[i].Name, result.Entities[j].Name
|
||||
if ea > eb {
|
||||
ea, eb = eb, ea
|
||||
}
|
||||
key := ea + "||" + eb
|
||||
a.noMergeMu.Lock()
|
||||
rounds, ok := a.noMergeMarkers[key]
|
||||
if ok {
|
||||
rounds--
|
||||
if rounds <= 0 {
|
||||
delete(a.noMergeMarkers, key)
|
||||
} else {
|
||||
a.noMergeMarkers[key] = rounds
|
||||
}
|
||||
}
|
||||
a.noMergeMu.Unlock()
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
|
||||
if sim > 0.5 {
|
||||
if sim > 0.75 {
|
||||
candidates++
|
||||
a.enqueueConsolidationTask(ConsolidationTask{
|
||||
Type: "entity_merge",
|
||||
@ -2059,14 +2316,19 @@ func entitySimilarity(a, b string) float64 {
|
||||
setA[string(runesA[i:i+2])] = true
|
||||
}
|
||||
|
||||
intersect := 0
|
||||
setB := make(map[string]bool)
|
||||
for i := 0; i < len(runesB)-1; i++ {
|
||||
if setA[string(runesB[i:i+2])] {
|
||||
setB[string(runesB[i:i+2])] = true
|
||||
}
|
||||
|
||||
intersect := 0
|
||||
for bg := range setA {
|
||||
if setB[bg] {
|
||||
intersect++
|
||||
}
|
||||
}
|
||||
|
||||
union := len(setA) + len(runesB) - 1 - intersect
|
||||
union := len(setA) + len(setB) - intersect
|
||||
if union <= 0 {
|
||||
return 0
|
||||
}
|
||||
@ -2155,19 +2417,10 @@ func (a *Agent) processConsolidation(input string) {
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
})
|
||||
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 == "" {
|
||||
return "请指定输出通道名称,可选: voice, email, screen, http"
|
||||
}
|
||||
a.currentOutputChannel = channel
|
||||
return fmt.Sprintf("输出通道已切换至: %s,后续输出将通过此通道", channel)
|
||||
}
|
||||
|
||||
// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
|
||||
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||||
channel, _ := tc.Arguments["channel"].(string)
|
||||
@ -2269,7 +2522,7 @@ func (a *Agent) runChildTask(taskID, task string) {
|
||||
// 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具
|
||||
allTools := a.buildToolDefs()
|
||||
childTools := make([]interface{}, 0, len(allTools))
|
||||
outputTools := map[string]bool{"output_send": true, "output_set_channel": true, "output_list_channels": true, "spawn_child": true, "plgreload": true}
|
||||
outputTools := map[string]bool{"output_send": true, "output_list_channels": true, "spawn_child": true, "plgreload": true}
|
||||
for _, t := range allTools {
|
||||
toolMap, ok := t.(map[string]interface{})
|
||||
if !ok {
|
||||
@ -2313,7 +2566,7 @@ func (a *Agent) runChildTask(taskID, task string) {
|
||||
for _, ct := range resp.ToolCalls {
|
||||
var result string
|
||||
switch {
|
||||
case ct.Name == "output_send" || ct.Name == "output_set_channel" || ct.Name == "output_list_channels":
|
||||
case ct.Name == "output_send" || ct.Name == "output_list_channels":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
|
||||
case ct.Name == "spawn_child" || ct.Name == "plgreload":
|
||||
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
|
||||
@ -2399,9 +2652,7 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||
if err := a.providerManager.SetDefault(name); err != nil {
|
||||
return fmt.Sprintf("切换失败: %v", err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.provider = a.providerManager.Get(name)
|
||||
a.mu.Unlock()
|
||||
return fmt.Sprintf("已切换到 LLM 源: %s", name)
|
||||
|
||||
default:
|
||||
@ -2409,24 +2660,48 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
}
|
||||
|
||||
// mediaDataURL 从 pendingMedia 构建 data URL,返回最终 URL。
|
||||
func (a *Agent) mediaDataURL(defaultMime string) string {
|
||||
if a.pendingMedia == nil {
|
||||
return ""
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = defaultMime
|
||||
}
|
||||
return "data:" + mime + ";base64," + data
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// mediaChat 调用指定 provider 的多模态 Chat,统一处理超时和错误。
|
||||
func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: maxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s失败: %v", resultPrefix, err)
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", resultPrefix, resp.Content)
|
||||
}
|
||||
|
||||
// executeDescribeImage 调用多模态模型描述当前图片。
|
||||
func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
|
||||
if a.pendingMedia == nil {
|
||||
return "没有待处理的图片数据"
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data == "" && url == "" {
|
||||
imgURL := a.mediaDataURL("image/png")
|
||||
if imgURL == "" {
|
||||
return "图片数据为空"
|
||||
}
|
||||
|
||||
providerName, _ := tc.Arguments["provider"].(string)
|
||||
detail, _ := tc.Arguments["detail"].(string)
|
||||
if detail == "" {
|
||||
detail = "high"
|
||||
}
|
||||
|
||||
p := a.providerManager.Get(providerName)
|
||||
if p == nil {
|
||||
p = a.provider
|
||||
@ -2437,12 +2712,9 @@ func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
|
||||
prompt = "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。"
|
||||
}
|
||||
|
||||
imgURL := url
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
imgURL = "data:" + mime + ";base64," + data
|
||||
detail, _ := tc.Arguments["detail"].(string)
|
||||
if detail == "" {
|
||||
detail = "high"
|
||||
}
|
||||
|
||||
msg := agentAPI.Message{
|
||||
@ -2452,17 +2724,7 @@ func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
|
||||
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: detail}},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: 2048,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("图片描述失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("[图片描述] %s", resp.Content)
|
||||
return a.mediaChat(p, msg, "图片描述", 2048)
|
||||
}
|
||||
|
||||
// executeTranscribeAudio 调用多模态模型转写/描述当前音频。
|
||||
@ -2470,10 +2732,8 @@ func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
|
||||
if a.pendingMedia == nil {
|
||||
return "没有待处理的音频数据"
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data == "" && url == "" {
|
||||
audURL := a.mediaDataURL("audio/wav")
|
||||
if audURL == "" {
|
||||
return "音频数据为空"
|
||||
}
|
||||
|
||||
@ -2488,14 +2748,6 @@ func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
|
||||
prompt = "请转写这段音频的内容。"
|
||||
}
|
||||
|
||||
audURL := url
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = "audio/wav"
|
||||
}
|
||||
audURL = "data:" + mime + ";base64," + data
|
||||
}
|
||||
|
||||
msg := agentAPI.Message{
|
||||
Role: "user",
|
||||
Blocks: []agentAPI.ContentBlock{
|
||||
@ -2503,17 +2755,7 @@ func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
|
||||
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: audURL}},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: 2048,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("音频转写失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("[音频转写] %s", resp.Content)
|
||||
return a.mediaChat(p, msg, "音频转写", 2048)
|
||||
}
|
||||
|
||||
// executeOCRImage 对图片执行 OCR 文字识别(通过多模态模型实现)。
|
||||
@ -2521,23 +2763,11 @@ func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string {
|
||||
if a.pendingMedia == nil {
|
||||
return "没有待处理的图片数据"
|
||||
}
|
||||
data, _ := a.pendingMedia["data"].(string)
|
||||
mime, _ := a.pendingMedia["mime"].(string)
|
||||
url, _ := a.pendingMedia["url"].(string)
|
||||
if data == "" && url == "" {
|
||||
imgURL := a.mediaDataURL("image/png")
|
||||
if imgURL == "" {
|
||||
return "图片数据为空"
|
||||
}
|
||||
|
||||
p := a.provider
|
||||
|
||||
imgURL := url
|
||||
if data != "" {
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
imgURL = "data:" + mime + ";base64," + data
|
||||
}
|
||||
|
||||
msg := agentAPI.Message{
|
||||
Role: "user",
|
||||
Blocks: []agentAPI.ContentBlock{
|
||||
@ -2545,17 +2775,7 @@ func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string {
|
||||
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "high"}},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
|
||||
Messages: []agentAPI.Message{msg},
|
||||
MaxTokens: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("OCR 识别失败: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("[OCR 结果] %s", resp.Content)
|
||||
return a.mediaChat(a.provider, msg, "OCR 结果", 4096)
|
||||
}
|
||||
|
||||
// runStage — 运行阶段管道,若插件 Response 被设置则返回 true(短路)
|
||||
|
||||
@ -53,29 +53,6 @@ func TestExecuteOutputListChannelsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputChannelTool(t *testing.T) {
|
||||
a := &Agent{}
|
||||
tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{
|
||||
"channel": "voice",
|
||||
}}
|
||||
result := a.executeOutputChannelTool(tc)
|
||||
if a.currentOutputChannel != "voice" {
|
||||
t.Errorf("expected channel 'voice', got %q", a.currentOutputChannel)
|
||||
}
|
||||
if result == "" {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputChannelToolEmpty(t *testing.T) {
|
||||
a := &Agent{}
|
||||
tc := agentAPI.ToolCall{Name: "output_set_channel", Arguments: map[string]interface{}{}}
|
||||
result := a.executeOutputChannelTool(tc)
|
||||
if result != "请指定输出通道名称,可选: voice, email, screen, http" {
|
||||
t.Errorf("unexpected result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteOutputSendTool(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
io.RegisterDevice(&mockOutputDevice{
|
||||
@ -154,7 +131,6 @@ func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
|
||||
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
foundSetChannel := false
|
||||
foundSend := false
|
||||
foundList := false
|
||||
for _, td := range tools {
|
||||
@ -168,17 +144,12 @@ func TestBuildToolDefsOutputToolsAlwaysPresent(t *testing.T) {
|
||||
}
|
||||
name, _ := fn["name"].(string)
|
||||
switch name {
|
||||
case "output_set_channel":
|
||||
foundSetChannel = true
|
||||
case "output_send":
|
||||
foundSend = true
|
||||
case "output_list_channels":
|
||||
foundList = true
|
||||
}
|
||||
}
|
||||
if !foundSetChannel {
|
||||
t.Error("output_set_channel should always be in tools")
|
||||
}
|
||||
if !foundSend {
|
||||
t.Error("output_send should always be in tools")
|
||||
}
|
||||
@ -191,8 +162,8 @@ func TestGetAllToolsEmpty(t *testing.T) {
|
||||
io := agentIO.NewIOManager()
|
||||
a := &Agent{io: io}
|
||||
tools := a.buildToolDefs()
|
||||
// should have at least output_set_channel, output_send, output_list_channels
|
||||
if len(tools) < 3 {
|
||||
t.Errorf("expected at least 3 tools, got %d", len(tools))
|
||||
// should have at least output_send, output_list_channels
|
||||
if len(tools) < 2 {
|
||||
t.Errorf("expected at least 2 tools, got %d", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,13 +24,17 @@ type ContextEvent struct {
|
||||
Vector vector.Vector `json:"-"` // 缓存向量,避免重复计算
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
|
||||
// RelevanceContext — 基于相关性的上下文管理,非固定阈值
|
||||
type RelevanceContext struct {
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
veczer *vector.TFIDFVectorizer
|
||||
trained bool
|
||||
savePath string // 持久化路径,空则不持久化
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
veczer *vector.TFIDFVectorizer
|
||||
trained bool
|
||||
savePath string // 持久化路径,空则不持久化
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string) *RelevanceContext {
|
||||
@ -88,16 +92,36 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.save()
|
||||
}
|
||||
|
||||
// save 无锁版本,Append/Prune 内部持有锁时调用
|
||||
// save 无锁版本,Append/Prune 内部持有锁时调用。带 debounce,每 5s 写一次盘。
|
||||
func (c *RelevanceContext) save() error {
|
||||
if c.savePath == "" {
|
||||
return nil
|
||||
}
|
||||
if !c.dirty {
|
||||
c.dirty = true
|
||||
if c.saveTimer == nil {
|
||||
c.saveTimer = time.AfterFunc(contextFlushInterval, c.flush)
|
||||
} else {
|
||||
c.saveTimer.Reset(contextFlushInterval)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) flush() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !c.dirty {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(c.events)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
return os.WriteFile(c.savePath, data, 0644)
|
||||
if err := os.WriteFile(c.savePath, data, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
c.dirty = false
|
||||
}
|
||||
|
||||
// Prune — 基于当前输入计算每条上下文的相关性,归档最不相关的
|
||||
@ -110,19 +134,31 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
return 0
|
||||
}
|
||||
|
||||
// 保护最近 10 条记录不被淘汰,从更早的记录中选择淘汰对象
|
||||
protectCount := 10
|
||||
if protectCount > len(c.events) {
|
||||
protectCount = len(c.events)
|
||||
}
|
||||
protected := c.events[len(c.events)-protectCount:]
|
||||
candidates := c.events[:len(c.events)-protectCount]
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 确保向量化器已训练
|
||||
c.ensureTrained()
|
||||
|
||||
queryVec := c.veczer.Vectorize(currentInput)
|
||||
|
||||
// 计算每条上下文与当前输入的相关性
|
||||
// 计算每条候选上下文与当前输入的相关性
|
||||
type scored struct {
|
||||
event *ContextEvent
|
||||
score float64
|
||||
idx int
|
||||
}
|
||||
scoredEvents := make([]scored, len(c.events))
|
||||
for i, evt := range c.events {
|
||||
scoredEvents := make([]scored, len(candidates))
|
||||
for i, evt := range candidates {
|
||||
score := vector.CosineSimilarity(queryVec, evt.Vector)
|
||||
scoredEvents[i] = scored{event: evt, score: score, idx: i}
|
||||
}
|
||||
@ -132,18 +168,23 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
return scoredEvents[i].score > scoredEvents[j].score
|
||||
})
|
||||
|
||||
// 保留 topK 最相关的
|
||||
// 从候选中选 topK 最相关的保留,其余淘汰
|
||||
keepCount := topK - len(protected)
|
||||
if keepCount < 0 {
|
||||
keepCount = 0
|
||||
}
|
||||
keep := scoredEvents
|
||||
if len(keep) > topK {
|
||||
keep = keep[:topK]
|
||||
if len(keep) > keepCount {
|
||||
keep = keep[:keepCount]
|
||||
}
|
||||
archive := scoredEvents[topK:]
|
||||
archive := scoredEvents[keepCount:]
|
||||
|
||||
// 重建 events 为保留的
|
||||
c.events = make([]*ContextEvent, len(keep))
|
||||
for i, s := range keep {
|
||||
c.events[i] = s.event
|
||||
// 重建 events 为保留的候选 + 受保护的最新记录
|
||||
c.events = make([]*ContextEvent, 0, len(keep)+len(protected))
|
||||
for _, s := range keep {
|
||||
c.events = append(c.events, s.event)
|
||||
}
|
||||
c.events = append(c.events, protected...)
|
||||
|
||||
// 按时间重新排序
|
||||
sort.Slice(c.events, func(i, j int) bool {
|
||||
|
||||
@ -55,7 +55,7 @@ func TestContextFormat(t *testing.T) {
|
||||
|
||||
func TestContextPruneKeepsTopK(t *testing.T) {
|
||||
ctx := NewRelevanceContext("")
|
||||
for i := 0; i < 10; i++ {
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
@ -74,8 +74,9 @@ func TestContextPruneKeepsTopK(t *testing.T) {
|
||||
archived := ctx.Prune("微积分", 5, nil) // nil docStore → 不归档,只裁剪
|
||||
_ = archived
|
||||
|
||||
if ctx.Len() > 5 {
|
||||
t.Errorf("after prune to 5, len should be ≤5, got %d", ctx.Len())
|
||||
// protectCount=10 + topK=5 → 最多保留 15
|
||||
if ctx.Len() > 15 {
|
||||
t.Errorf("after prune to 5, len should be ≤15, got %d", ctx.Len())
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,7 +99,8 @@ func TestContextPruneWithDocStore(t *testing.T) {
|
||||
|
||||
func TestContextAppendAfterPrune(t *testing.T) {
|
||||
ctx := NewRelevanceContext("")
|
||||
for i := 0; i < 10; i++ {
|
||||
// 需要超过 protectCount(10) + topK(3) 个事件才能产生修剪候选
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "user",
|
||||
@ -107,13 +109,13 @@ func TestContextAppendAfterPrune(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx.Prune("hello", 3, nil)
|
||||
if ctx.Len() > 3 {
|
||||
t.Errorf("expected ≤3 after prune, got %d", ctx.Len())
|
||||
if ctx.Len() > 13 { // 10 protected + 3 topK
|
||||
t.Errorf("expected ≤13 after prune, got %d", ctx.Len())
|
||||
}
|
||||
|
||||
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "new message"})
|
||||
if ctx.Len() != 4 {
|
||||
t.Errorf("after append, expected 4, got %d", ctx.Len())
|
||||
if ctx.Len() > 14 {
|
||||
t.Errorf("expected ≤14 after append, got %d", ctx.Len())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -103,10 +103,6 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (h *StageHost) RunStageAll(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
h.RunStage(stage, ctx)
|
||||
}
|
||||
|
||||
func (h *StageHost) ToolCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
@ -146,7 +146,7 @@ func TestStageHostRunStageAll(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||
host.RunStage(sdk.StageAfterOutput, &sdk.StageContext{})
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 handlers called, got %d", count)
|
||||
|
||||
@ -188,6 +188,10 @@ func (r *ConfigRegistry) Close() error {
|
||||
return r.db.Close()
|
||||
}
|
||||
|
||||
var defaultSources = map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
}
|
||||
|
||||
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -230,17 +234,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
||||
set("core.llm.max_tokens", "4096")
|
||||
set("core.llm.thinking_enabled", "false")
|
||||
|
||||
sources := map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "openai", "adapter_path": "adapters/openai.lua"},
|
||||
"anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "api_key": "", "thinking_enabled": "false", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"},
|
||||
"gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "api_key": "", "thinking_enabled": "false", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"},
|
||||
"mistral": {"base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "api_key": "", "thinking_enabled": "false", "adapter": "mistral", "adapter_path": "adapters/mistral.lua"},
|
||||
"groq": {"base_url": "https://api.groq.com", "model": "llama3-70b-8192", "api_key": "", "thinking_enabled": "false", "adapter": "groq", "adapter_path": "adapters/groq.lua"},
|
||||
"github": {"base_url": "https://models.inference.ai.azure.com", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "github", "adapter_path": "adapters/github.lua"},
|
||||
"ollama": {"base_url": "http://localhost:11434", "model": "llama3", "api_key": "", "thinking_enabled": "false", "adapter": "ollama", "adapter_path": "adapters/ollama.lua"},
|
||||
}
|
||||
for name, props := range sources {
|
||||
for name, props := range defaultSources {
|
||||
p := "core.llm.sources." + name
|
||||
set(p+".base_url", props["base_url"])
|
||||
set(p+".model", props["model"])
|
||||
@ -306,24 +300,14 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
||||
reg(ConfigDef{Key: "core.llm.max_tokens", Default: "4096", Type: "int", DisplayName: "最大 Token", Description: "每次生成的最大 Token 数", Category: "llm"})
|
||||
reg(ConfigDef{Key: "core.llm.thinking_enabled", Default: "false", Type: "bool", DisplayName: "深度思考", Description: "启用深度思考模式(如 DeepSeek R1 的思维链输出)", Category: "llm"})
|
||||
|
||||
sources := map[string]map[string]string{
|
||||
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
|
||||
"openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "openai", "adapter_path": "adapters/openai.lua"},
|
||||
"anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "api_key": "", "thinking_enabled": "false", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"},
|
||||
"gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "api_key": "", "thinking_enabled": "false", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"},
|
||||
"mistral": {"base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "api_key": "", "thinking_enabled": "false", "adapter": "mistral", "adapter_path": "adapters/mistral.lua"},
|
||||
"groq": {"base_url": "https://api.groq.com", "model": "llama3-70b-8192", "api_key": "", "thinking_enabled": "false", "adapter": "groq", "adapter_path": "adapters/groq.lua"},
|
||||
"github": {"base_url": "https://models.inference.ai.azure.com", "model": "gpt-4o", "api_key": "", "thinking_enabled": "false", "adapter": "github", "adapter_path": "adapters/github.lua"},
|
||||
"ollama": {"base_url": "http://localhost:11434", "model": "llama3", "api_key": "", "thinking_enabled": "false", "adapter": "ollama", "adapter_path": "adapters/ollama.lua"},
|
||||
}
|
||||
for name := range sources {
|
||||
for name := range defaultSources {
|
||||
p := "core.llm.sources." + name
|
||||
reg(ConfigDef{Key: p + ".base_url", Default: sources[name]["base_url"], Type: "string", DisplayName: name + " API 地址", Description: name + " LLM API 基础地址", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".model", Default: sources[name]["model"], Type: "string", DisplayName: name + " 模型", Description: name + " 使用的模型名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".base_url", Default: defaultSources[name]["base_url"], Type: "string", DisplayName: name + " API 地址", Description: name + " LLM API 基础地址", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".model", Default: defaultSources[name]["model"], Type: "string", DisplayName: name + " 模型", Description: name + " 使用的模型名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".api_key", Default: "", Type: "password", DisplayName: name + " API 密钥", Description: name + " API 密钥", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".thinking_enabled", Default: sources[name]["thinking_enabled"], Type: "bool", DisplayName: name + " 深度思考", Description: name + " 启用深度思考模式", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter", Default: sources[name]["adapter"], Type: "string", DisplayName: name + " 适配器", Description: name + " 协议适配器名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter_path", Default: sources[name]["adapter_path"], Type: "string", DisplayName: name + " 适配器路径", Description: name + " 适配器脚本路径", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".thinking_enabled", Default: defaultSources[name]["thinking_enabled"], Type: "bool", DisplayName: name + " 深度思考", Description: name + " 启用深度思考模式", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter", Default: defaultSources[name]["adapter"], Type: "string", DisplayName: name + " 适配器", Description: name + " 协议适配器名称", Category: "sources"})
|
||||
reg(ConfigDef{Key: p + ".adapter_path", Default: defaultSources[name]["adapter_path"], Type: "string", DisplayName: name + " 适配器路径", Description: name + " 适配器脚本路径", Category: "sources"})
|
||||
}
|
||||
|
||||
reg(ConfigDef{Key: "core.defaults.image", Default: "homeagent/agent-base:latest", Type: "string", DisplayName: "默认镜像", Description: "Agent 默认 Docker 镜像", Category: "defaults"})
|
||||
@ -457,6 +441,17 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
|
||||
}
|
||||
return d
|
||||
}
|
||||
readFloat := func(key string, def float64) float64 {
|
||||
s := read(key, "")
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return f
|
||||
}
|
||||
readBool := func(key string, def bool) bool {
|
||||
s := read(key, "")
|
||||
if s == "" {
|
||||
@ -480,7 +475,7 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
|
||||
cfg.LLM.BaseURL = read("core.llm.base_url", cfg.LLM.BaseURL)
|
||||
cfg.LLM.APIKey = read("core.llm.api_key", cfg.LLM.APIKey)
|
||||
cfg.LLM.Adapter = read("core.llm.adapter", cfg.LLM.Adapter)
|
||||
cfg.LLM.Temperature = float64(readInt("core.llm.temperature", int(cfg.LLM.Temperature*100))) / 100
|
||||
cfg.LLM.Temperature = readFloat("core.llm.temperature", cfg.LLM.Temperature)
|
||||
cfg.LLM.MaxTokens = readInt("core.llm.max_tokens", cfg.LLM.MaxTokens)
|
||||
cfg.LLM.ThinkingEnabled = readBool("core.llm.thinking_enabled", cfg.LLM.ThinkingEnabled)
|
||||
|
||||
|
||||
@ -1,209 +0,0 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewManager(dataDir string) *Manager {
|
||||
return &Manager{dataDir: dataDir}
|
||||
}
|
||||
|
||||
type ContainerInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, cfg *types.AgentConfig) (*ContainerInfo, error) {
|
||||
args := []string{
|
||||
"create",
|
||||
"--name", "ha-" + string(cfg.ID),
|
||||
"--hostname", string(cfg.ID),
|
||||
"--restart", "no",
|
||||
"--stop-timeout", "10",
|
||||
"--memory", cfg.ResourceLimit.Memory,
|
||||
"--cpus", cfg.ResourceLimit.CPU,
|
||||
"--label", "homeagent.managed=true",
|
||||
"--label", "homeagent.agent-id=" + string(cfg.ID),
|
||||
}
|
||||
|
||||
if !cfg.ResourceLimit.Network {
|
||||
args = append(args, "--network", "none")
|
||||
}
|
||||
|
||||
args = append(args, cfg.Image)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker create: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(string(out))
|
||||
return &ContainerInfo{ID: id, Name: "ha-" + string(cfg.ID), Status: "created", Image: cfg.Image}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Start(ctx context.Context, containerID string) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "start", containerID)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker start: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop(ctx context.Context, containerID string) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "stop", "--time", "5", containerID)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker stop: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(ctx context.Context, containerID string) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "rm", "-f", containerID)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker rm: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Inspect(ctx context.Context, containerID string) (*ContainerInfo, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", "inspect", containerID)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker inspect: %w", err)
|
||||
}
|
||||
|
||||
var containers []struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
State struct {
|
||||
Status string `json:"Status"`
|
||||
} `json:"State"`
|
||||
Config struct {
|
||||
Image string `json:"Image"`
|
||||
} `json:"Config"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(out, &containers); err != nil {
|
||||
return nil, fmt.Errorf("parse inspect: %w", err)
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
return nil, fmt.Errorf("container %s not found", containerID)
|
||||
}
|
||||
|
||||
c := containers[0]
|
||||
return &ContainerInfo{
|
||||
ID: c.ID,
|
||||
Name: strings.TrimPrefix(c.Name, "/"),
|
||||
Status: c.State.Status,
|
||||
Image: c.Config.Image,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) WaitHealthy(ctx context.Context, containerID string) error {
|
||||
args := []string{
|
||||
"exec", containerID,
|
||||
"agentd", "--probe",
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (m *Manager) Exec(ctx context.Context, containerID string, cmdArgs []string) ([]byte, error) {
|
||||
args := append([]string{"exec", containerID}, cmdArgs...)
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker exec: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Commit(ctx context.Context, containerID string, imageTag string) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "commit", containerID, imageTag)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker commit: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SaveImage(ctx context.Context, imageTag string, outputPath string) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "save", "-o", outputPath, imageTag)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker save: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) LoadImage(ctx context.Context, inputPath string) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", "load", "-i", inputPath)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("docker load: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ListManaged(ctx context.Context) ([]ContainerInfo, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", "ps", "-a",
|
||||
"--filter", "label=homeagent.managed=true",
|
||||
"--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}",
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker ps: %w", err)
|
||||
}
|
||||
|
||||
var containers []ContainerInfo
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 4)
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
containers = append(containers, ContainerInfo{
|
||||
ID: parts[0], Name: parts[1], Status: parts[2], Image: parts[3],
|
||||
})
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
package embed
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Embedder interface {
|
||||
Embed(text string) ([]float64, error)
|
||||
Similarity(a, b []float64) float64
|
||||
Dimension() int
|
||||
}
|
||||
|
||||
type OllamaEmbedder struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
model string
|
||||
dimension int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewOllamaEmbedder(baseURL, model string, dimension int) *OllamaEmbedder {
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:11434"
|
||||
}
|
||||
if model == "" {
|
||||
model = "nomic-embed-text"
|
||||
}
|
||||
if dimension <= 0 {
|
||||
dimension = 768
|
||||
}
|
||||
|
||||
return &OllamaEmbedder{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
baseURL: baseURL,
|
||||
model: model,
|
||||
dimension: dimension,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *OllamaEmbedder) Embed(text string) ([]float64, error) {
|
||||
if text == "" {
|
||||
return make([]float64, e.dimension), nil
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"model": e.model,
|
||||
"prompt": text,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
resp, err := e.client.Post(e.baseURL+"/api/embeddings", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ollama api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
return result.Embedding, nil
|
||||
}
|
||||
|
||||
func (e *OllamaEmbedder) Similarity(a, b []float64) float64 {
|
||||
return cosineSimilarity(a, b)
|
||||
}
|
||||
|
||||
func (e *OllamaEmbedder) Dimension() int {
|
||||
return e.dimension
|
||||
}
|
||||
|
||||
type HashEmbedder struct {
|
||||
dimension int
|
||||
}
|
||||
|
||||
func NewHashEmbedder(dimension int) *HashEmbedder {
|
||||
if dimension <= 0 {
|
||||
dimension = 64
|
||||
}
|
||||
return &HashEmbedder{dimension: dimension}
|
||||
}
|
||||
|
||||
func (e *HashEmbedder) Embed(text string) ([]float64, error) {
|
||||
vec := make([]float64, e.dimension)
|
||||
runes := []rune(text)
|
||||
if len(runes) == 0 {
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
// Character-level hash embedding
|
||||
for i, r := range runes {
|
||||
h := hashRune(r)
|
||||
idx := i % e.dimension
|
||||
vec[idx] += float64(h) / 65536.0
|
||||
}
|
||||
|
||||
// Normalize
|
||||
mag := 0.0
|
||||
for _, v := range vec {
|
||||
mag += v * v
|
||||
}
|
||||
if mag > 0 {
|
||||
mag = math.Sqrt(mag)
|
||||
for i := range vec {
|
||||
vec[i] /= mag
|
||||
}
|
||||
}
|
||||
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
func (e *HashEmbedder) Similarity(a, b []float64) float64 {
|
||||
return cosineSimilarity(a, b)
|
||||
}
|
||||
|
||||
func (e *HashEmbedder) Dimension() int {
|
||||
return e.dimension
|
||||
}
|
||||
|
||||
func hashRune(r rune) uint64 {
|
||||
h := uint64(r)
|
||||
h ^= h >> 33
|
||||
h *= 0xff51afd7ed558ccd
|
||||
h ^= h >> 33
|
||||
h *= 0xc4ceb9fe1a85ec53
|
||||
h ^= h >> 33
|
||||
return h
|
||||
}
|
||||
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) || len(a) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i] * b[i]
|
||||
}
|
||||
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
@ -164,16 +164,13 @@ func (v *VM) LoadAdapter(path string) error {
|
||||
|
||||
func (v *VM) CallTransformRequest(name, rawJSON string) (string, error) {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("adapter %s not loaded", name)
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
fn := adapter.RawGetString("transform_request")
|
||||
if fn == nil {
|
||||
return "", fmt.Errorf("adapter %s missing transform_request", name)
|
||||
@ -194,16 +191,13 @@ func (v *VM) CallTransformRequest(name, rawJSON string) (string, error) {
|
||||
|
||||
func (v *VM) CallTransformResponse(name, rawJSON string) (string, error) {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return rawJSON, nil
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
fn := adapter.RawGetString("transform_response")
|
||||
if fn == nil {
|
||||
return rawJSON, nil
|
||||
@ -224,16 +218,13 @@ func (v *VM) CallTransformResponse(name, rawJSON string) (string, error) {
|
||||
|
||||
func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return rawLine, nil
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
fn := adapter.RawGetString("transform_stream_chunk")
|
||||
if fn == nil {
|
||||
return rawLine, nil
|
||||
@ -257,16 +248,13 @@ func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) {
|
||||
|
||||
func (v *VM) GetAdapterEndpoint(name string) string {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if ep := adapter.RawGetString("endpoint"); ep != nil {
|
||||
return ep.String()
|
||||
}
|
||||
@ -275,16 +263,13 @@ func (v *VM) GetAdapterEndpoint(name string) string {
|
||||
|
||||
func (v *VM) GetAdapterHeaders(name string) map[string]string {
|
||||
v.mu.Lock()
|
||||
adapter, ok := v.loaded[name]
|
||||
v.mu.Unlock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
adapter, ok := v.loaded[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
headers := make(map[string]string)
|
||||
if ht := adapter.RawGetString("headers"); ht != nil {
|
||||
if tbl, ok := ht.(*lua.LTable); ok {
|
||||
|
||||
@ -37,11 +37,13 @@ type Store struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
docs map[string]*Doc
|
||||
summaries []string // 用于训练向量化器
|
||||
summaries []string // 用于训练向量化器,最大 10000 条
|
||||
|
||||
dirty bool
|
||||
}
|
||||
|
||||
const maxSummaries = 10000
|
||||
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
@ -83,11 +85,15 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
|
||||
s.docs[doc.ID] = doc
|
||||
|
||||
// 增量训练向量化器并加入向量索引
|
||||
s.addSummary(doc.Summary)
|
||||
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, doc.Meta)
|
||||
|
||||
// 更新训练集
|
||||
s.summaries = append(s.summaries, doc.Summary)
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, doc.ID+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
s.dirty = true
|
||||
return nil
|
||||
@ -110,12 +116,13 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
|
||||
content := strings.Join(parts, "\n")
|
||||
contentHash := simpleHash(content)
|
||||
|
||||
// 去重:检查是否已有相同 hash 的文档(在锁内完成创建/更新)
|
||||
summary := summarizeEntries(entries)
|
||||
tags := extractTags(entries)
|
||||
entities := extractEntities(entries)
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
// 去重
|
||||
for _, d := range s.docs {
|
||||
if d.Meta != nil && d.Meta["content_hash"] == contentHash {
|
||||
d.UpdatedAt = time.Now()
|
||||
@ -146,8 +153,20 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error
|
||||
Meta: map[string]string{"content_hash": contentHash},
|
||||
}
|
||||
s.docs[id] = doc
|
||||
|
||||
// 增量训练向量化器并加入向量索引
|
||||
s.addSummary(summary)
|
||||
vec := s.veczer.Vectorize(summary + " " + content)
|
||||
s.vec.Insert(id, summary, vec, nil)
|
||||
|
||||
s.dirty = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// 立即写盘
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
data, _ := json.MarshalIndent(doc, "", " ")
|
||||
os.WriteFile(path, data, 0644)
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
@ -166,8 +185,7 @@ func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
var docs []*Doc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
delete(s.docs, r.ID)
|
||||
s.vec.Remove(r.ID)
|
||||
s.removeDoc(r.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
@ -266,14 +284,39 @@ func (s *Store) Remove(id string) {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.docs[id]; ok {
|
||||
delete(s.docs, id)
|
||||
s.vec.Remove(id)
|
||||
s.removeDoc(id)
|
||||
s.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// ——— internal ———
|
||||
|
||||
// addSummary 添加一条摘要到训练集,超限时截断并触发重索引。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) addSummary(summary string) {
|
||||
s.summaries = append(s.summaries, summary)
|
||||
if len(s.summaries) > maxSummaries {
|
||||
n := maxSummaries / 2
|
||||
copy(s.summaries, s.summaries[len(s.summaries)-n:])
|
||||
s.summaries = s.summaries[:n]
|
||||
s.veczer.Train(s.summaries)
|
||||
s.vec = vector.NewStore()
|
||||
for _, doc := range s.docs {
|
||||
vec := s.veczer.Vectorize(doc.Summary + " " + doc.Content)
|
||||
s.vec.Insert(doc.ID, doc.Summary, vec, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeDoc 从内存索引和磁盘删除文档。
|
||||
// 调用方必须已持有 s.mu 写锁。
|
||||
func (s *Store) removeDoc(id string) {
|
||||
delete(s.docs, id)
|
||||
s.vec.Remove(id)
|
||||
path := filepath.Join(s.dir, id+".json")
|
||||
os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) loadAll() error {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
|
||||
@ -526,7 +526,7 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) {
|
||||
// MergeEntities 合并两个实体:将 sourceName 的所有信息合并到 targetName
|
||||
// 1. sourceName 的所有关系重新指向 targetName
|
||||
// 2. targetName 的 mention_count 增加 sourceName 的计数
|
||||
// 3. sourceName 标记为 merged
|
||||
// 3. sourceName 彻底删除(不再残留 @merged_ 实体)
|
||||
// 返回 (关系的重定向数, error)
|
||||
func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
g.mu.Lock()
|
||||
@ -554,7 +554,7 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return 0, fmt.Errorf("cannot merge entity with itself")
|
||||
}
|
||||
|
||||
// 重定向 source → target 的关系(作为 source)
|
||||
// 重定向 source → target 的活跃关系(作为 source)
|
||||
res, err := tx.Exec(
|
||||
`UPDATE relations SET source_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE source_id = ? AND status = 'active'`,
|
||||
@ -565,7 +565,7 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
}
|
||||
redirectedSource, _ := res.RowsAffected()
|
||||
|
||||
// 重定向 source → target 的关系(作为 target)
|
||||
// 重定向 source → target 的活跃关系(作为 target)
|
||||
res, err = tx.Exec(
|
||||
`UPDATE relations SET target_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE target_id = ? AND status = 'active'`,
|
||||
@ -586,6 +586,12 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 清理 source 残留的非活跃关系(archived/deleted),否则外键约束阻止删除实体
|
||||
_, err = tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, sourceID, sourceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 更新 target 的 mention_count
|
||||
_, err = tx.Exec(
|
||||
`UPDATE entities SET mention_count = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
@ -595,14 +601,8 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 标记 source 为 merged(改名避免 UNIQUE 冲突)
|
||||
_, err = tx.Exec(
|
||||
`UPDATE entities SET name = ? || '@merged_' || ?,
|
||||
mention_count = 0,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
sourceName, time.Now().Format("20060102150405"), sourceID,
|
||||
)
|
||||
// 彻底删除 source 实体(所有关系已重定向,自引用已删除)
|
||||
_, err = tx.Exec(`DELETE FROM entities WHERE id = ?`, sourceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@ -615,6 +615,36 @@ func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// DeleteEntity 彻底删除一个实体及其所有关联关系。
|
||||
func (g *GraphDB) DeleteEntity(name string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var id int64
|
||||
err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", name).Scan(&id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("entity '%s' not found: %w", name, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, id, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`DELETE FROM entities WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (g *GraphDB) Archive(days int) (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
@ -88,7 +88,7 @@ func (d *Distiller) flush() {
|
||||
if len(d.records) == 0 {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.jsonl", time.Now().UnixNano()))
|
||||
path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.tsv", time.Now().UnixNano()))
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
log.Printf("[memory] flush error: %v", err)
|
||||
@ -113,7 +113,8 @@ func (d *Distiller) loadExisting() {
|
||||
}
|
||||
var files []fileInfo
|
||||
for _, entry := range entries {
|
||||
if filepath.Ext(entry.Name()) != ".jsonl" {
|
||||
ext := filepath.Ext(entry.Name())
|
||||
if ext != ".tsv" && ext != ".jsonl" {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
@ -296,8 +297,8 @@ func extractName(s string) string {
|
||||
}{
|
||||
{"我叫", ""},
|
||||
{"我的名字是", ""},
|
||||
{"我是", ""},
|
||||
{"名字是", ""},
|
||||
{"我是", ""},
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
for _, p := range patterns {
|
||||
@ -313,6 +314,10 @@ func extractName(s string) string {
|
||||
candidate = candidate[:idx]
|
||||
}
|
||||
}
|
||||
// "我是张三"(姓名) vs "我是一个程序员"(职业):名字通常 ≤4 字符
|
||||
if p.prefix == "我是" && len([]rune(candidate)) > 4 {
|
||||
continue
|
||||
}
|
||||
if len(candidate) > 0 && len(candidate) < 20 {
|
||||
return candidate
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// .so 插件必须导出函数 NewPlugin,签名与 NativeFactory 一致:
|
||||
@ -27,7 +27,7 @@ const (
|
||||
|
||||
type dynamicPlugin struct {
|
||||
name string
|
||||
impl sdkext.Plugin
|
||||
impl pubsdk.Plugin
|
||||
}
|
||||
|
||||
func (p *dynamicPlugin) Name() string { return p.name }
|
||||
@ -105,9 +105,9 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err
|
||||
}
|
||||
return nil, fmt.Errorf("NewPlugin %s returned non-error second value", name)
|
||||
}
|
||||
plg, ok := outs[0].Interface().(sdkext.Plugin)
|
||||
plg, ok := outs[0].Interface().(pubsdk.Plugin)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("NewPlugin in %s returned value that does not implement external sdk.Plugin", soPath)
|
||||
return nil, fmt.Errorf("NewPlugin in %s returned value that does not implement pubsdk.Plugin", soPath)
|
||||
}
|
||||
|
||||
return &dynamicPlugin{name: name, impl: plg}, nil
|
||||
|
||||
@ -1,178 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Stage string
|
||||
|
||||
const (
|
||||
StageOnInput Stage = "on_input"
|
||||
StagePreAction Stage = "pre_action"
|
||||
StagePostAction Stage = "post_action"
|
||||
StageBeforeToolcall Stage = "before_toolcall"
|
||||
StageAfterToolcall Stage = "after_toolcall"
|
||||
StageBeforeOutput Stage = "before_output"
|
||||
StageAfterOutput Stage = "after_output"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventSystem EventType = "system"
|
||||
EventAll EventType = "*"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type MemItem struct {
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type StageContext struct {
|
||||
RawMessage string
|
||||
UserID string
|
||||
GroupID string
|
||||
ContextMsgs []map[string]interface{}
|
||||
LLMText string
|
||||
ToolCalls []ToolCall
|
||||
ToolResults []ToolResult
|
||||
FinalText string
|
||||
Response *string
|
||||
Phase Stage
|
||||
Memory []MemItem
|
||||
Extra map[string]interface{}
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
|
||||
type SettingsAPI interface {
|
||||
Get(key string) (interface{}, error)
|
||||
Set(key string, value interface{}) error
|
||||
List(prefix string) ([]string, error)
|
||||
}
|
||||
|
||||
type MemoryAPI interface {
|
||||
Recall(query string, topK int) ([]MemItem, error)
|
||||
Commit(triples []map[string]string) error
|
||||
Introspect() (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
type KnowledgeAPI interface {
|
||||
Search(query string, topK int) ([]MemItem, error)
|
||||
Create(name, content string) error
|
||||
List() ([]string, error)
|
||||
}
|
||||
|
||||
type EventHandler func(event *Event)
|
||||
type StageHandler func(ctx *StageContext) error
|
||||
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
type PluginAPI struct {
|
||||
Name string
|
||||
Version string
|
||||
|
||||
tools map[string]ToolHandler
|
||||
stages map[Stage][]StageHandler
|
||||
events map[EventType][]EventHandler
|
||||
eventBus EventBus
|
||||
memAPI MemoryAPI
|
||||
knowAPI KnowledgeAPI
|
||||
settAPI SettingsAPI
|
||||
}
|
||||
|
||||
func NewPluginAPI(name, version string, bus EventBus, mem MemoryAPI, know KnowledgeAPI) *PluginAPI {
|
||||
return &PluginAPI{
|
||||
Name: name,
|
||||
Version: version,
|
||||
tools: make(map[string]ToolHandler),
|
||||
stages: make(map[Stage][]StageHandler),
|
||||
events: make(map[EventType][]EventHandler),
|
||||
eventBus: bus,
|
||||
memAPI: mem,
|
||||
knowAPI: know,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PluginAPI) RegisterTool(name string, handler ToolHandler) error {
|
||||
if _, ok := p.tools[name]; ok {
|
||||
return fmt.Errorf("tool %s already registered by plugin %s", name, p.Name)
|
||||
}
|
||||
p.tools[name] = handler
|
||||
if p.eventBus != nil {
|
||||
p.eventBus.Publish(&Event{
|
||||
Type: EventSystem,
|
||||
Source: p.Name,
|
||||
Payload: map[string]interface{}{"action": "register_tool", "tool": name},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PluginAPI) RegisterStage(stage Stage, handler StageHandler) {
|
||||
p.stages[stage] = append(p.stages[stage], handler)
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Subscribe(eventType EventType, handler EventHandler) {
|
||||
p.events[eventType] = append(p.events[eventType], handler)
|
||||
if p.eventBus != nil {
|
||||
p.eventBus.Subscribe(eventType, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Publish(evt *Event) {
|
||||
if p.eventBus != nil {
|
||||
p.eventBus.Publish(evt)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Tools() map[string]ToolHandler {
|
||||
return p.tools
|
||||
}
|
||||
|
||||
func (p *PluginAPI) StageHandlers(stage Stage) []StageHandler {
|
||||
return p.stages[stage]
|
||||
}
|
||||
|
||||
func (p *PluginAPI) Memory() MemoryAPI { return p.memAPI }
|
||||
func (p *PluginAPI) Knowledge() KnowledgeAPI { return p.knowAPI }
|
||||
func (p *PluginAPI) Settings() SettingsAPI { return p.settAPI }
|
||||
func (p *PluginAPI) SetSettings(s SettingsAPI) { p.settAPI = s }
|
||||
|
||||
func AllStages() map[Stage]bool {
|
||||
return map[Stage]bool{
|
||||
StageOnInput: true,
|
||||
StagePreAction: true,
|
||||
StagePostAction: true,
|
||||
StageBeforeToolcall: true,
|
||||
StageAfterToolcall: true,
|
||||
StageBeforeOutput: true,
|
||||
StageAfterOutput: true,
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import "fmt"
|
||||
|
||||
type EventBus interface {
|
||||
Publish(event *Event)
|
||||
Subscribe(eventType EventType, handler EventHandler) func()
|
||||
}
|
||||
|
||||
type InProcessBus struct {
|
||||
subs map[EventType][]EventHandler
|
||||
}
|
||||
|
||||
func NewInProcessBus() *InProcessBus {
|
||||
return &InProcessBus{
|
||||
subs: make(map[EventType][]EventHandler),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *InProcessBus) Publish(evt *Event) {
|
||||
for _, h := range b.subs[EventAll] {
|
||||
h(evt)
|
||||
}
|
||||
if evt.Type != EventAll {
|
||||
for _, h := range b.subs[evt.Type] {
|
||||
h(evt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *InProcessBus) Subscribe(eventType EventType, handler EventHandler) func() {
|
||||
b.subs[eventType] = append(b.subs[eventType], handler)
|
||||
return func() {
|
||||
list := b.subs[eventType]
|
||||
for i, h := range list {
|
||||
if fmt.Sprintf("%p", h) == fmt.Sprintf("%p", handler) {
|
||||
b.subs[eventType] = append(list[:i], list[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInProcessBus(t *testing.T) {
|
||||
bus := NewInProcessBus()
|
||||
var called bool
|
||||
|
||||
bus.Subscribe(EventRawInput, func(evt *Event) {
|
||||
called = true
|
||||
if evt.Source != "test" {
|
||||
t.Errorf("expected source test, got %s", evt.Source)
|
||||
}
|
||||
})
|
||||
|
||||
bus.Publish(&Event{
|
||||
Type: EventRawInput,
|
||||
Source: "test",
|
||||
})
|
||||
|
||||
if !called {
|
||||
t.Error("handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInProcessBusWildcard(t *testing.T) {
|
||||
bus := NewInProcessBus()
|
||||
count := 0
|
||||
|
||||
bus.Subscribe(EventAll, func(evt *Event) {
|
||||
count++
|
||||
})
|
||||
|
||||
bus.Publish(&Event{Type: EventRawInput, Source: "s1"})
|
||||
bus.Publish(&Event{Type: EventToolCall, Source: "s2"})
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAPI(t *testing.T) {
|
||||
bus := NewInProcessBus()
|
||||
api := NewPluginAPI("test", "1.0.0", bus, nil, nil)
|
||||
|
||||
if api.Name != "test" {
|
||||
t.Errorf("expected test, got %s", api.Name)
|
||||
}
|
||||
if api.Version != "1.0.0" {
|
||||
t.Errorf("expected 1.0.0, got %s", api.Version)
|
||||
}
|
||||
|
||||
var stageCalled bool
|
||||
api.RegisterStage(StageOnInput, func(ctx *StageContext) error {
|
||||
stageCalled = true
|
||||
if ctx.RawMessage != "hello" {
|
||||
t.Errorf("expected hello, got %s", ctx.RawMessage)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := &StageContext{RawMessage: "hello"}
|
||||
for _, handler := range api.StageHandlers(StageOnInput) {
|
||||
handler(ctx)
|
||||
}
|
||||
|
||||
if !stageCalled {
|
||||
t.Error("stage handler was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAPITool(t *testing.T) {
|
||||
api := NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
||||
|
||||
err := api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register tool: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := api.Tools()["test_tool"]; !ok {
|
||||
t.Error("tool not found")
|
||||
}
|
||||
|
||||
// duplicate registration should fail
|
||||
err = api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error on duplicate tool registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginAPIStageShortCircuit(t *testing.T) {
|
||||
api := NewPluginAPI("test", "1.0.0", nil, nil, nil)
|
||||
|
||||
api.RegisterStage(StageOnInput, func(ctx *StageContext) error {
|
||||
resp := "intercepted"
|
||||
ctx.Response = &resp
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx := &StageContext{RawMessage: "hello"}
|
||||
handlers := api.StageHandlers(StageOnInput)
|
||||
if len(handlers) != 1 {
|
||||
t.Fatalf("expected 1 handler, got %d", len(handlers))
|
||||
}
|
||||
handlers[0](ctx)
|
||||
|
||||
if ctx.Response == nil || *ctx.Response != "intercepted" {
|
||||
t.Errorf("expected intercepted, got %v", ctx.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllStages(t *testing.T) {
|
||||
stages := AllStages()
|
||||
expected := []Stage{
|
||||
StageOnInput, StagePreAction, StagePostAction,
|
||||
StageBeforeToolcall, StageAfterToolcall,
|
||||
StageBeforeOutput, StageAfterOutput,
|
||||
}
|
||||
for _, s := range expected {
|
||||
if !stages[s] {
|
||||
t.Errorf("missing stage: %s", s)
|
||||
}
|
||||
}
|
||||
if len(stages) != len(expected) {
|
||||
t.Errorf("expected %d stages, got %d", len(expected), len(stages))
|
||||
}
|
||||
}
|
||||
@ -345,6 +345,10 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
|
||||
timeout = d
|
||||
}
|
||||
}
|
||||
// SSH 命令自动使用更长的超时(5 分钟)
|
||||
if timeoutStr == "" && (strings.HasPrefix(command, "ssh ") || strings.HasPrefix(command, "ssh -")) {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
|
||||
rows := uint16(24)
|
||||
cols := uint16(80)
|
||||
|
||||
@ -12,6 +12,34 @@ import (
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// shellUnquote 拆解命令字符串,处理单引号/双引号包裹的参数
|
||||
func shellUnquote(s string) []string {
|
||||
var args []string
|
||||
var cur strings.Builder
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '\'' && !inDouble:
|
||||
inSingle = !inSingle
|
||||
case c == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
args = append(args, cur.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
@ -79,7 +107,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
||||
parts := shellUnquote(command)
|
||||
if len(parts) == 0 {
|
||||
return map[string]interface{}{"error": "command is required"}, nil
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
if workdir != "" {
|
||||
cmd.Dir = workdir
|
||||
}
|
||||
|
||||
@ -81,8 +81,9 @@ func TestCmdRunWithStderr(t *testing.T) {
|
||||
}
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
// ls with a nonexistent path writes to stderr and returns non-zero exit code
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "echo out && echo err >&2 && exit 1",
|
||||
"command": "ls /tmp/cmd_test_nonexistent_xxxxx",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -95,14 +96,11 @@ func TestCmdRunWithStderr(t *testing.T) {
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["stdout"] != "out" {
|
||||
t.Fatalf("expected stdout 'out', got %v", resp["stdout"])
|
||||
if stderr, ok := resp["stderr"].(string); !ok || stderr == "" {
|
||||
t.Fatalf("expected stderr output, got %q", stderr)
|
||||
}
|
||||
if resp["stderr"] != "err" {
|
||||
t.Fatalf("expected stderr 'err', got %v", resp["stderr"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 1 {
|
||||
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||
if exitCode, ok := resp["exit_code"].(float64); !ok || exitCode == 0 {
|
||||
t.Fatalf("expected non-zero exit code, got %v", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,7 +195,7 @@ func TestCmdRunNonZeroExit(t *testing.T) {
|
||||
|
||||
handler := tc.handlers["cmd_run"]
|
||||
result, err := handler(map[string]interface{}{
|
||||
"command": "exit 42",
|
||||
"command": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -210,8 +208,8 @@ func TestCmdRunNonZeroExit(t *testing.T) {
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||||
}
|
||||
if resp["exit_code"].(float64) != 42 {
|
||||
t.Fatalf("expected exit code 42, got %v", resp["exit_code"])
|
||||
if resp["exit_code"].(float64) != 1 {
|
||||
t.Fatalf("expected exit code 1, got %v", resp["exit_code"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,15 +10,30 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
var downloadClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("redirect to disallowed scheme: %s", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
PluginDir string // 由 main.go 设置
|
||||
Reg *plugin.Registry // 由 main.go 设置
|
||||
@ -260,10 +275,18 @@ func (p *Plugin) handlePluginByID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ======== Core Logic ========
|
||||
|
||||
func (p *Plugin) installFromURL(url string) (interface{}, error) {
|
||||
log.Printf("[pluginmgr] downloading: %s", url)
|
||||
func (p *Plugin) installFromURL(rawURL string) (interface{}, error) {
|
||||
log.Printf("[pluginmgr] downloading: %s", rawURL)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("unsupported URL scheme: %s (only http/https allowed)", parsed.Scheme)
|
||||
}
|
||||
|
||||
resp, err := downloadClient.Get(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
@ -290,7 +291,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"status": "running",
|
||||
"uptime": time.Since(h.startTime).String(),
|
||||
"agents": len(agents),
|
||||
"version": "0.1.0",
|
||||
"version": meta.Version,
|
||||
"startedAt": h.startTime,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,33 +1,6 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type KnowledgeAPI = sdkext.KnowledgeAPI
|
||||
type Knowledge = sdkext.Knowledge
|
||||
|
||||
type knowledgeImpl struct{ ks *knowledge.Store }
|
||||
|
||||
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
||||
|
||||
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
got := k.ks.Search(query, topK)
|
||||
out := make([]*Knowledge, len(got))
|
||||
for i, item := range got {
|
||||
out[i] = &Knowledge{Name: item.Name, Content: item.Content}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Add(name, content string) error {
|
||||
if k.ks == nil { return nil }
|
||||
return k.ks.Add(name, content)
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) List() ([]string, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
return k.ks.List(), nil
|
||||
}
|
||||
type KnowledgeAPI = pubsdk.KnowledgeAPI
|
||||
type Knowledge = pubsdk.Knowledge
|
||||
|
||||
29
internal/sdk/knowledge_impl.go
Normal file
29
internal/sdk/knowledge_impl.go
Normal file
@ -0,0 +1,29 @@
|
||||
package sdk
|
||||
|
||||
import "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
|
||||
type knowledgeImpl struct{ ks *knowledge.Store }
|
||||
|
||||
func NewKnowledge(ks *knowledge.Store) KnowledgeAPI { return &knowledgeImpl{ks: ks} }
|
||||
|
||||
func (k *knowledgeImpl) Search(query string, topK int) ([]*Knowledge, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
got := k.ks.Search(query, topK)
|
||||
out := make([]*Knowledge, len(got))
|
||||
for i, item := range got {
|
||||
out[i] = &Knowledge{Name: item.Name, Content: item.Content}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) Add(name, content string) error {
|
||||
if k.ks == nil { return nil }
|
||||
return k.ks.Add(name, content)
|
||||
}
|
||||
|
||||
func (k *knowledgeImpl) List() ([]string, error) {
|
||||
if k.ks == nil { return nil, nil }
|
||||
return k.ks.List(), nil
|
||||
}
|
||||
|
||||
var _ KnowledgeAPI = (*knowledgeImpl)(nil)
|
||||
@ -1,29 +1,5 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type LLMAPI = sdkext.LLMAPI
|
||||
|
||||
type llmImpl struct{ mgr *agentAPI.ProviderManager }
|
||||
|
||||
func NewLLM(mgr *agentAPI.ProviderManager) LLMAPI { return &llmImpl{mgr: mgr} }
|
||||
|
||||
func (l *llmImpl) ListSources() []string {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.List()
|
||||
}
|
||||
|
||||
func (l *llmImpl) SetSource(name string) error {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.SetDefault(name)
|
||||
}
|
||||
|
||||
func (l *llmImpl) CurrentSource() string {
|
||||
if l.mgr == nil { return "" }
|
||||
p := l.mgr.Default()
|
||||
if p == nil { return "" }
|
||||
return p.Name()
|
||||
}
|
||||
type LLMAPI = pubsdk.LLMAPI
|
||||
|
||||
26
internal/sdk/llm_impl.go
Normal file
26
internal/sdk/llm_impl.go
Normal file
@ -0,0 +1,26 @@
|
||||
package sdk
|
||||
|
||||
import agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
|
||||
type llmImpl struct{ mgr *agentAPI.ProviderManager }
|
||||
|
||||
func NewLLM(mgr *agentAPI.ProviderManager) LLMAPI { return &llmImpl{mgr: mgr} }
|
||||
|
||||
func (l *llmImpl) ListSources() []string {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.List()
|
||||
}
|
||||
|
||||
func (l *llmImpl) SetSource(name string) error {
|
||||
if l.mgr == nil { return nil }
|
||||
return l.mgr.SetDefault(name)
|
||||
}
|
||||
|
||||
func (l *llmImpl) CurrentSource() string {
|
||||
if l.mgr == nil { return "" }
|
||||
p := l.mgr.Default()
|
||||
if p == nil { return "" }
|
||||
return p.Name()
|
||||
}
|
||||
|
||||
var _ LLMAPI = (*llmImpl)(nil)
|
||||
@ -1,101 +1,14 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type MemoryAPI = sdkext.MemoryAPI
|
||||
type Entity = sdkext.Entity
|
||||
type Relation = sdkext.Relation
|
||||
type Triple = sdkext.Triple
|
||||
type MemoryAPI = pubsdk.MemoryAPI
|
||||
type Entity = pubsdk.Entity
|
||||
type Relation = pubsdk.Relation
|
||||
type Triple = pubsdk.Triple
|
||||
|
||||
type TextMemoryAPI = sdkext.TextMemoryAPI
|
||||
type TextEvent = sdkext.TextEvent
|
||||
type TextMemoryAPI = pubsdk.TextMemoryAPI
|
||||
type TextEvent = pubsdk.TextEvent
|
||||
|
||||
type DocMemoryAPI = sdkext.DocMemoryAPI
|
||||
type Doc = sdkext.Doc
|
||||
|
||||
type graphMemory struct{ db *memory.GraphDB }
|
||||
|
||||
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
|
||||
|
||||
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
|
||||
if m.db == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
result, err := m.db.Recall(query, nil, depth, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
entities := make([]Entity, len(result.Entities))
|
||||
for i, e := range result.Entities {
|
||||
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
|
||||
}
|
||||
relations := make([]Relation, len(result.Relations))
|
||||
for i, r := range result.Relations {
|
||||
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
|
||||
}
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil { return nil }
|
||||
ts := make([]memory.Triple, len(triples))
|
||||
for i, t := range triples {
|
||||
ts[i] = memory.Triple{Subject: t.Subject, Relation: t.Relation, Object: t.Object}
|
||||
}
|
||||
_, _, err := m.db.Commit(ts, "plugin", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
|
||||
if m.db == nil { return map[string]interface{}{}, nil }
|
||||
return m.db.Introspect()
|
||||
}
|
||||
|
||||
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.MergeEntities(source, target)
|
||||
}
|
||||
|
||||
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.Purge(criteria, mode)
|
||||
}
|
||||
|
||||
type textMemoryImpl struct{ tm *text.Memory }
|
||||
|
||||
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
|
||||
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil { return nil }
|
||||
return m.tm.Append(text.Event{Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel})
|
||||
}
|
||||
|
||||
type docMemoryImpl struct{ ds *doc.Store }
|
||||
|
||||
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil { return nil }
|
||||
got := m.ds.Query(text, topK)
|
||||
out := make([]*Doc, len(got))
|
||||
for i, d := range got {
|
||||
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error {
|
||||
if m.ds == nil { return nil }
|
||||
return m.ds.Insert(&doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content})
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Remove(id string) { if m.ds != nil { m.ds.Remove(id) } }
|
||||
func (m *docMemoryImpl) Stats() map[string]interface{} {
|
||||
if m.ds == nil { return map[string]interface{}{} }
|
||||
return m.ds.Stats()
|
||||
}
|
||||
type DocMemoryAPI = pubsdk.DocMemoryAPI
|
||||
type Doc = pubsdk.Doc
|
||||
|
||||
92
internal/sdk/memory_impl.go
Normal file
92
internal/sdk/memory_impl.go
Normal file
@ -0,0 +1,92 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
type graphMemory struct{ db *memory.GraphDB }
|
||||
|
||||
func NewGraphMemory(db *memory.GraphDB) MemoryAPI { return &graphMemory{db: db} }
|
||||
|
||||
func (m *graphMemory) Recall(query []string, depth int) ([]Entity, []Relation, error) {
|
||||
if m.db == nil { return nil, nil, nil }
|
||||
result, err := m.db.Recall(query, nil, depth, "")
|
||||
if err != nil { return nil, nil, err }
|
||||
entities := make([]Entity, len(result.Entities))
|
||||
for i, e := range result.Entities {
|
||||
entities[i] = Entity{Name: e.Name, Type: e.Type, MentionCount: e.MentionCount}
|
||||
}
|
||||
relations := make([]Relation, len(result.Relations))
|
||||
for i, r := range result.Relations {
|
||||
relations[i] = Relation{SourceName: r.SourceName, TargetName: r.TargetName, RelationType: r.RelationType}
|
||||
}
|
||||
return entities, relations, nil
|
||||
}
|
||||
|
||||
func (m *graphMemory) Commit(triples []Triple) error {
|
||||
if m.db == nil { return nil }
|
||||
ts := make([]memory.Triple, len(triples))
|
||||
for i, t := range triples {
|
||||
ts[i] = memory.Triple{Subject: t.Subject, Relation: t.Relation, Object: t.Object}
|
||||
}
|
||||
_, _, err := m.db.Commit(ts, "plugin", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *graphMemory) Introspect() (map[string]interface{}, error) {
|
||||
if m.db == nil { return map[string]interface{}{}, nil }
|
||||
return m.db.Introspect()
|
||||
}
|
||||
|
||||
func (m *graphMemory) MergeEntities(source, target string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.MergeEntities(source, target)
|
||||
}
|
||||
|
||||
func (m *graphMemory) Purge(criteria map[string]string, mode string) (int, error) {
|
||||
if m.db == nil { return 0, nil }
|
||||
return m.db.Purge(criteria, mode)
|
||||
}
|
||||
|
||||
type textMemoryImpl struct{ tm *text.Memory }
|
||||
|
||||
func NewTextMemory(tm *text.Memory) TextMemoryAPI { return &textMemoryImpl{tm: tm} }
|
||||
|
||||
func (m *textMemoryImpl) Append(evt TextEvent) error {
|
||||
if m.tm == nil { return nil }
|
||||
return m.tm.Append(text.Event{
|
||||
Timestamp: evt.Timestamp, Source: evt.Role, Input: evt.Content, AgentID: evt.Channel,
|
||||
})
|
||||
}
|
||||
|
||||
type docMemoryImpl struct{ ds *doc.Store }
|
||||
|
||||
func NewDocMemory(ds *doc.Store) DocMemoryAPI { return &docMemoryImpl{ds: ds} }
|
||||
|
||||
func (m *docMemoryImpl) Query(text string, topK int) []*Doc {
|
||||
if m.ds == nil { return nil }
|
||||
got := m.ds.Query(text, topK)
|
||||
out := make([]*Doc, len(got))
|
||||
for i, d := range got {
|
||||
out[i] = &Doc{ID: d.ID, Title: d.Summary, Content: d.Content}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Insert(d *Doc) error {
|
||||
if m.ds == nil { return nil }
|
||||
return m.ds.Insert(&doc.Doc{ID: d.ID, Summary: d.Title, Content: d.Content})
|
||||
}
|
||||
|
||||
func (m *docMemoryImpl) Remove(id string) { if m.ds != nil { m.ds.Remove(id) } }
|
||||
|
||||
func (m *docMemoryImpl) Stats() map[string]interface{} {
|
||||
if m.ds == nil { return map[string]interface{}{} }
|
||||
return m.ds.Stats()
|
||||
}
|
||||
|
||||
var _ MemoryAPI = (*graphMemory)(nil)
|
||||
var _ TextMemoryAPI = (*textMemoryImpl)(nil)
|
||||
var _ DocMemoryAPI = (*docMemoryImpl)(nil)
|
||||
@ -3,7 +3,7 @@ package sdk
|
||||
import (
|
||||
"log"
|
||||
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||
)
|
||||
@ -14,61 +14,68 @@ type Plugin interface {
|
||||
Stop() error
|
||||
}
|
||||
|
||||
type ToolHandler = sdkext.ToolHandler
|
||||
type StageHandler = sdkext.StageHandler
|
||||
type ToolHandler = pubsdk.ToolHandler
|
||||
type StageHandler = pubsdk.StageHandler
|
||||
|
||||
type Stage = sdkext.Stage
|
||||
type Stage = pubsdk.Stage
|
||||
|
||||
const (
|
||||
StageOnInput = sdkext.StageOnInput
|
||||
StagePreAction = sdkext.StagePreAction
|
||||
StagePostAction = sdkext.StagePostAction
|
||||
StageBeforeToolcall = sdkext.StageBeforeToolcall
|
||||
StageAfterToolcall = sdkext.StageAfterToolcall
|
||||
StageBeforeOutput = sdkext.StageBeforeOutput
|
||||
StageAfterOutput = sdkext.StageAfterOutput
|
||||
StageOnInput = pubsdk.StageOnInput
|
||||
StagePreAction = pubsdk.StagePreAction
|
||||
StagePostAction = pubsdk.StagePostAction
|
||||
StageBeforeToolcall = pubsdk.StageBeforeToolcall
|
||||
StageAfterToolcall = pubsdk.StageAfterToolcall
|
||||
StageBeforeOutput = pubsdk.StageBeforeOutput
|
||||
StageAfterOutput = pubsdk.StageAfterOutput
|
||||
)
|
||||
|
||||
type StageContext = sdkext.StageContext
|
||||
type MemItem = sdkext.MemItem
|
||||
type ToolCall = sdkext.ToolCall
|
||||
type ToolResult = sdkext.ToolResult
|
||||
type ToolDef = sdkext.ToolDef
|
||||
|
||||
type ToolRegistrar = func(name string, def ToolDef, handler ToolHandler) error
|
||||
type StageRegistrar = func(stage Stage, handler StageHandler)
|
||||
type APIRegistrar = func(name string) error
|
||||
|
||||
type ioAdapter struct{ iom *agentIO.IOManager }
|
||||
|
||||
func (i ioAdapter) InjectInterruptText(source, channel, text string) {
|
||||
if i.iom != nil {
|
||||
i.iom.InjectInterrupt(source, channel, map[string]interface{}{"type": "text", "content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (i ioAdapter) InjectText(source, channel, text string) {
|
||||
if i.iom != nil {
|
||||
i.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (i ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
if i.iom != nil {
|
||||
i.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text, "no_memory": true})
|
||||
}
|
||||
}
|
||||
type StageContext = pubsdk.StageContext
|
||||
type MemItem = pubsdk.MemItem
|
||||
type ToolCall = pubsdk.ToolCall
|
||||
type ToolResult = pubsdk.ToolResult
|
||||
type ToolDef = pubsdk.ToolDef
|
||||
type IOInjector = pubsdk.IOInjector
|
||||
type ToolRegistrar = pubsdk.ToolRegistrar
|
||||
type StageRegistrar = pubsdk.StageRegistrar
|
||||
type APIRegistrar = pubsdk.APIRegistrar
|
||||
|
||||
type PluginSDK struct {
|
||||
*sdkext.PluginSDK
|
||||
*pubsdk.PluginSDK
|
||||
iom *agentIO.IOManager
|
||||
eventBus *events.Bus
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func New(name string, iom *agentIO.IOManager, eventBus *events.Bus, mem MemoryAPI, textMem TextMemoryAPI, docMem DocMemoryAPI, know KnowledgeAPI, llm LLMAPI, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar) *PluginSDK {
|
||||
base := sdkext.New(name, sett, regTool, regStage, regAPI)
|
||||
base.SetIOInjector(ioAdapter{iom: iom})
|
||||
// ioAdapter 桥接 IOManager 到公共 SDK 的 IOInjector 接口,
|
||||
// 确保外部插件通过 s.InjectText() 等方法的调用能被路由到内核 IO 层。
|
||||
type ioAdapter struct{ iom *agentIO.IOManager }
|
||||
|
||||
func (a ioAdapter) InjectInterruptText(source, channel, text string) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInterrupt(source, channel, map[string]interface{}{"type": "text", "content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (a ioAdapter) InjectText(source, channel, text string) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text})
|
||||
}
|
||||
}
|
||||
|
||||
func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text, "no_memory": true})
|
||||
}
|
||||
}
|
||||
|
||||
func New(name string, iom *agentIO.IOManager, eventBus *events.Bus, mem MemoryAPI,
|
||||
textMem TextMemoryAPI, docMem DocMemoryAPI, know KnowledgeAPI, llm LLMAPI,
|
||||
sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar,
|
||||
) *PluginSDK {
|
||||
base := pubsdk.New(name, sett, regTool, regStage, regAPI)
|
||||
if iom != nil {
|
||||
base.SetIOInjector(ioAdapter{iom: iom})
|
||||
}
|
||||
base.SetMemoryAPI(mem)
|
||||
base.SetTextMemoryAPI(textMem)
|
||||
base.SetDocMemoryAPI(docMem)
|
||||
@ -152,5 +159,3 @@ func (s *PluginSDK) Subscribe(eventType events.EventType, handler events.Handler
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func (s *PluginSDK) Logger() *log.Logger { return s.logger }
|
||||
|
||||
@ -1,106 +1,6 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
sdkext "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
)
|
||||
import pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
|
||||
type ConfigDef = sdkext.ConfigDef
|
||||
type SettingsAPI = sdkext.SettingsAPI
|
||||
|
||||
type settingsImpl struct {
|
||||
pluginName string
|
||||
reg *internalConfig.ConfigRegistry
|
||||
}
|
||||
|
||||
func NewSettings(name string, reg *internalConfig.ConfigRegistry) SettingsAPI {
|
||||
return &settingsImpl{pluginName: name, reg: reg}
|
||||
}
|
||||
|
||||
func (s *settingsImpl) Get(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) Set(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) List(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) GetCore(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetCore(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListCore(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.List(prefix), nil
|
||||
}
|
||||
func (s *settingsImpl) GetPlugin(plugin, key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetPlugin(plugin, key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(plugin).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListPlugin(plugin, prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) RegisterDef(def sdkext.ConfigDef) {
|
||||
if s.reg == nil { return }
|
||||
s.reg.PluginConfig(s.pluginName).RegisterDef(internalConfig.ConfigDef{
|
||||
Key: def.Key, Type: def.Type, DisplayName: def.DisplayName, Description: def.Description,
|
||||
Category: def.Category, Options: def.Options,
|
||||
Default: stringifyDefault(def.Default),
|
||||
})
|
||||
}
|
||||
func (s *settingsImpl) Defs(prefix string) []*sdkext.ConfigDef {
|
||||
if s.reg == nil { return nil }
|
||||
defs := s.reg.PluginConfig(s.pluginName).ListDefs(prefix)
|
||||
out := make([]*sdkext.ConfigDef, len(defs))
|
||||
for i, d := range defs {
|
||||
cpy := sdkext.ConfigDef{
|
||||
Key: d.Key, Default: d.Default, Type: d.Type, DisplayName: d.DisplayName,
|
||||
Description: d.Description, Category: d.Category, Options: d.Options,
|
||||
}
|
||||
out[i] = &cpy
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *settingsImpl) Dump() map[string]interface{} {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Dump()
|
||||
}
|
||||
func (s *settingsImpl) Plugins() []string {
|
||||
if s.reg == nil { return nil }
|
||||
keys := s.reg.List("config_")
|
||||
names := make([]string, 0, len(keys)+1)
|
||||
names = append(names, "core")
|
||||
for _, k := range keys {
|
||||
if len(k) > 7 { names = append(names, k[7:]) }
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func stringifyDefault(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case bool:
|
||||
if x { return "true" }
|
||||
return "false"
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
type SettingsAPI = pubsdk.SettingsAPI
|
||||
type ConfigDef = pubsdk.ConfigDef
|
||||
|
||||
102
internal/sdk/settings_impl.go
Normal file
102
internal/sdk/settings_impl.go
Normal file
@ -0,0 +1,102 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
)
|
||||
|
||||
type settingsImpl struct {
|
||||
pluginName string
|
||||
reg *internalConfig.ConfigRegistry
|
||||
}
|
||||
|
||||
func NewSettings(name string, reg *internalConfig.ConfigRegistry) SettingsAPI {
|
||||
return &settingsImpl{pluginName: name, reg: reg}
|
||||
}
|
||||
|
||||
func (s *settingsImpl) Get(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) Set(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(s.pluginName).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) List(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(s.pluginName).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) GetCore(key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetCore(key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListCore(prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.List(prefix), nil
|
||||
}
|
||||
func (s *settingsImpl) GetPlugin(plugin, key string) (interface{}, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).Get(key)
|
||||
}
|
||||
func (s *settingsImpl) SetPlugin(plugin, key string, value interface{}) error {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.PluginConfig(plugin).Set(key, value)
|
||||
}
|
||||
func (s *settingsImpl) ListPlugin(plugin, prefix string) ([]string, error) {
|
||||
if s.reg == nil { return nil, nil }
|
||||
return s.reg.PluginConfig(plugin).List(prefix)
|
||||
}
|
||||
func (s *settingsImpl) RegisterDef(def ConfigDef) {
|
||||
if s.reg == nil { return }
|
||||
s.reg.PluginConfig(s.pluginName).RegisterDef(internalConfig.ConfigDef{
|
||||
Key: def.Key, Type: def.Type, DisplayName: def.DisplayName, Description: def.Description,
|
||||
Category: def.Category, Options: def.Options,
|
||||
Default: stringifyDefault(def.Default),
|
||||
})
|
||||
}
|
||||
func (s *settingsImpl) Defs(prefix string) []*ConfigDef {
|
||||
if s.reg == nil { return nil }
|
||||
defs := s.reg.PluginConfig(s.pluginName).ListDefs(prefix)
|
||||
out := make([]*ConfigDef, len(defs))
|
||||
for i, d := range defs {
|
||||
// ConfigDef = pubsdk.ConfigDef (type alias), so direct conversion works
|
||||
cpy := ConfigDef{
|
||||
Key: d.Key, Type: d.Type, DisplayName: d.DisplayName, Description: d.Description,
|
||||
Category: d.Category, Options: d.Options,
|
||||
}
|
||||
out[i] = &cpy
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *settingsImpl) Dump() map[string]interface{} {
|
||||
if s.reg == nil { return nil }
|
||||
return s.reg.Dump()
|
||||
}
|
||||
func (s *settingsImpl) Plugins() []string {
|
||||
if s.reg == nil { return nil }
|
||||
keys := s.reg.List("config_")
|
||||
names := make([]string, 0, len(keys)+1)
|
||||
names = append(names, "core")
|
||||
for _, k := range keys {
|
||||
if len(k) > 7 { names = append(names, k[7:]) }
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func stringifyDefault(v interface{}) string {
|
||||
if v == nil { return "" }
|
||||
switch x := v.(type) {
|
||||
case string: return x
|
||||
case bool:
|
||||
if x { return "true" }
|
||||
return "false"
|
||||
default: return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure settingsImpl satisfies SettingsAPI (pubsdk.SettingsAPI via type alias).
|
||||
var _ SettingsAPI = (*settingsImpl)(nil)
|
||||
@ -1,196 +0,0 @@
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/container"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
container *container.Manager
|
||||
snapshots map[types.AgentID][]types.Snapshot
|
||||
}
|
||||
|
||||
func NewManager(dataDir string, cm *container.Manager) *Manager {
|
||||
return &Manager{
|
||||
dataDir: filepath.Join(dataDir, "snapshots"),
|
||||
container: cm,
|
||||
snapshots: make(map[types.AgentID][]types.Snapshot),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, agentID types.AgentID, containerID string, reason string) (*types.Snapshot, error) {
|
||||
snapDir := filepath.Join(m.dataDir, string(agentID))
|
||||
if err := os.MkdirAll(snapDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create snapshot dir: %w", err)
|
||||
}
|
||||
|
||||
snapID := types.SnapshotID(fmt.Sprintf("snap_%s_%d", agentID, time.Now().UnixNano()))
|
||||
imageTag := fmt.Sprintf("homeagent/snap-%s:%s", agentID, snapID)
|
||||
imagePath := filepath.Join(snapDir, string(snapID)+".tar")
|
||||
|
||||
if err := m.container.Commit(ctx, containerID, imageTag); err != nil {
|
||||
return nil, fmt.Errorf("commit container: %w", err)
|
||||
}
|
||||
if err := m.container.SaveImage(ctx, imageTag, imagePath); err != nil {
|
||||
return nil, fmt.Errorf("save image: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(imagePath)
|
||||
var size int64
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
snap := types.Snapshot{
|
||||
ID: snapID,
|
||||
AgentID: agentID,
|
||||
CreatedAt: time.Now(),
|
||||
Reason: reason,
|
||||
Size: size,
|
||||
DockerImage: imageTag,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.snapshots[agentID] = append(m.snapshots[agentID], snap)
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Printf("[snapshot] created %s for agent %s (reason: %s, size: %d bytes)", snapID, agentID, reason, size)
|
||||
|
||||
m.enforceLimit(agentID)
|
||||
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Restore(ctx context.Context, agentID types.AgentID, containerID string, snapID types.SnapshotID) error {
|
||||
m.mu.RLock()
|
||||
snapshots := m.snapshots[agentID]
|
||||
var target *types.Snapshot
|
||||
for _, s := range snapshots {
|
||||
if s.ID == snapID && s.Valid {
|
||||
target = &s
|
||||
break
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
if target == nil {
|
||||
return fmt.Errorf("snapshot %s not found or invalid", snapID)
|
||||
}
|
||||
|
||||
snapDir := filepath.Join(m.dataDir, string(agentID))
|
||||
imagePath := filepath.Join(snapDir, string(snapID)+".tar")
|
||||
|
||||
if _, err := os.Stat(imagePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("snapshot file %s not found", imagePath)
|
||||
}
|
||||
|
||||
if err := m.container.Stop(ctx, containerID); err != nil {
|
||||
log.Printf("[snapshot] warning: stop container during restore: %v", err)
|
||||
}
|
||||
|
||||
if err := m.container.Remove(ctx, containerID); err != nil {
|
||||
return fmt.Errorf("remove container for restore: %w", err)
|
||||
}
|
||||
|
||||
if err := m.container.LoadImage(ctx, imagePath); err != nil {
|
||||
return fmt.Errorf("load snapshot image: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[snapshot] restored agent %s to snapshot %s", agentID, snapID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) List(agentID types.AgentID) []types.Snapshot {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
snapshots := m.snapshots[agentID]
|
||||
result := make([]types.Snapshot, len(snapshots))
|
||||
copy(result, snapshots)
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].CreatedAt.After(result[j].CreatedAt)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *Manager) Latest(agentID types.AgentID) *types.Snapshot {
|
||||
snapshots := m.List(agentID)
|
||||
if len(snapshots) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &snapshots[0]
|
||||
}
|
||||
|
||||
func (m *Manager) MarkInvalid(agentID types.AgentID, snapID types.SnapshotID) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for i, s := range m.snapshots[agentID] {
|
||||
if s.ID == snapID {
|
||||
m.snapshots[agentID][i].Valid = false
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) enforceLimit(agentID types.AgentID) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
snapshots := m.snapshots[agentID]
|
||||
if len(snapshots) <= 20 {
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
return snapshots[i].CreatedAt.Before(snapshots[j].CreatedAt)
|
||||
})
|
||||
|
||||
toRemove := len(snapshots) - 20
|
||||
for i := 0; i < toRemove; i++ {
|
||||
s := snapshots[i]
|
||||
snapDir := filepath.Join(m.dataDir, string(agentID))
|
||||
imagePath := filepath.Join(snapDir, string(s.ID)+".tar")
|
||||
os.Remove(imagePath)
|
||||
}
|
||||
|
||||
m.snapshots[agentID] = snapshots[toRemove:]
|
||||
}
|
||||
|
||||
func (m *Manager) Cleanup(agentID types.AgentID, keep int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
snapshots := m.snapshots[agentID]
|
||||
if len(snapshots) <= keep {
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
return snapshots[i].CreatedAt.Before(snapshots[j].CreatedAt)
|
||||
})
|
||||
|
||||
toRemove := len(snapshots) - keep
|
||||
for i := 0; i < toRemove; i++ {
|
||||
s := snapshots[i]
|
||||
snapDir := filepath.Join(m.dataDir, string(agentID))
|
||||
imagePath := filepath.Join(snapDir, string(s.ID)+".tar")
|
||||
os.Remove(imagePath)
|
||||
}
|
||||
|
||||
m.snapshots[agentID] = snapshots[toRemove:]
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
package tokenizer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
jieba "github.com/yanyiwu/gojieba"
|
||||
)
|
||||
|
||||
type Jieba struct {
|
||||
mu sync.Mutex
|
||||
handle *jieba.Jieba
|
||||
}
|
||||
|
||||
var (
|
||||
global *Jieba
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func Global() *Jieba {
|
||||
once.Do(func() {
|
||||
global = &Jieba{
|
||||
handle: jieba.NewJieba(),
|
||||
}
|
||||
})
|
||||
return global
|
||||
}
|
||||
|
||||
func (j *Jieba) Close() {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
if j.handle != nil {
|
||||
j.handle.Free()
|
||||
j.handle = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Jieba) ExtractKeywords(text string, topK int) []string {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
|
||||
words := j.handle.ExtractWithWeight(text, topK)
|
||||
result := make([]string, 0, len(words))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, w := range words {
|
||||
if seen[w.Word] {
|
||||
continue
|
||||
}
|
||||
if len([]rune(w.Word)) < 2 {
|
||||
continue
|
||||
}
|
||||
seen[w.Word] = true
|
||||
result = append(result, w.Word)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (j *Jieba) Cut(text string) []string {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
|
||||
return j.handle.Cut(text, true)
|
||||
}
|
||||
|
||||
func (j *Jieba) Tag(text string) map[string]string {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
|
||||
words := j.handle.Tag(text)
|
||||
result := make(map[string]string, len(words))
|
||||
for _, pair := range words {
|
||||
if idx := strings.Index(pair, "/"); idx > 0 {
|
||||
result[pair[:idx]] = pair[idx+1:]
|
||||
} else {
|
||||
result[pair] = ""
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user