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:
root
2026-07-12 11:42:56 +08:00
parent dad62516a8
commit 1f1233b823
89 changed files with 1764 additions and 6578 deletions

View File

@ -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 {

View File

@ -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 重定向到 targetsource 标记为 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短路

View File

@ -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))
}
}

View File

@ -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 {

View File

@ -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())
}
}

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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))
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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()

View File

@ -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
}

View File

@ -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

View File

@ -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,
}
}

View File

@ -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
}
}
}
}

View File

@ -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))
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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"])
}
}

View File

@ -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)
}

View File

@ -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,
})
}

View File

@ -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

View 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)

View File

@ -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
View 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)

View File

@ -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

View 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)

View File

@ -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 }

View File

@ -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

View 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)

View File

@ -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:]
}

View File

@ -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
}