Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add contacts upsert tool with profile enrichment support

Lyric 33adfa77 cc9a7b34

+651 -39
+31
agent/engine_helpers.go
··· 88 88 if v, ok := params["path"].(string); ok && strings.TrimSpace(v) != "" { 89 89 out["path"] = truncateString(strings.TrimSpace(v), opts.MaxStringValueChars) 90 90 } 91 + case "contacts_upsert": 92 + if v, ok := params["contact_id"].(string); ok && strings.TrimSpace(v) != "" { 93 + out["contact_id"] = truncateString(strings.TrimSpace(v), opts.MaxStringValueChars) 94 + } 95 + if v, ok := params["subject_id"].(string); ok && strings.TrimSpace(v) != "" { 96 + out["subject_id"] = truncateString(strings.TrimSpace(v), opts.MaxStringValueChars) 97 + } 98 + if v, ok := params["kind"].(string); ok && strings.TrimSpace(v) != "" { 99 + out["kind"] = truncateString(strings.TrimSpace(v), 24) 100 + } 101 + if v, ok := params["status"].(string); ok && strings.TrimSpace(v) != "" { 102 + out["status"] = truncateString(strings.TrimSpace(v), 32) 103 + } 104 + if v, ok := params["contact_nickname"].(string); ok && strings.TrimSpace(v) != "" { 105 + out["contact_nickname"] = truncateString(strings.TrimSpace(v), 64) 106 + } 107 + if v, ok := params["pronouns"].(string); ok && strings.TrimSpace(v) != "" { 108 + out["pronouns"] = truncateString(strings.TrimSpace(v), 32) 109 + } 110 + if v, ok := params["timezone"].(string); ok && strings.TrimSpace(v) != "" { 111 + out["timezone"] = truncateString(strings.TrimSpace(v), 64) 112 + } 113 + if v, ok := params["preference_context"].(string); ok { 114 + out["has_preference_context"] = strings.TrimSpace(v) != "" 115 + } 116 + if _, ok := params["topic_weights"]; ok { 117 + out["has_topic_weights"] = true 118 + } 119 + if _, ok := params["persona_traits"]; ok { 120 + out["has_persona_traits"] = true 121 + } 91 122 case "contacts_list": 92 123 if v, ok := params["status"].(string); ok && strings.TrimSpace(v) != "" { 93 124 out["status"] = truncateString(strings.TrimSpace(v), 40)
+36
agent/engine_helpers_test.go
··· 58 58 t.Fatalf("unexpected limit summary: %#v", list["limit"]) 59 59 } 60 60 } 61 + 62 + func TestToolArgsSummary_ContactsUpsertSafeSummary(t *testing.T) { 63 + opts := DefaultLogOptions() 64 + got := toolArgsSummary("contacts_upsert", map[string]any{ 65 + "contact_id": "tg:@alice", 66 + "kind": "human", 67 + "status": "active", 68 + "timezone": "America/New_York", 69 + "preference_context": "private details should not be logged verbatim", 70 + "topic_weights": map[string]any{"go": 0.8}, 71 + }, opts) 72 + if got == nil { 73 + t.Fatalf("upsert summary should not be nil") 74 + } 75 + if got["contact_id"] != "tg:@alice" { 76 + t.Fatalf("unexpected contact_id summary: %#v", got["contact_id"]) 77 + } 78 + if got["kind"] != "human" { 79 + t.Fatalf("unexpected kind summary: %#v", got["kind"]) 80 + } 81 + if got["status"] != "active" { 82 + t.Fatalf("unexpected status summary: %#v", got["status"]) 83 + } 84 + if got["timezone"] != "America/New_York" { 85 + t.Fatalf("unexpected timezone summary: %#v", got["timezone"]) 86 + } 87 + if v, ok := got["has_preference_context"].(bool); !ok || !v { 88 + t.Fatalf("expected has_preference_context=true, got %#v", got["has_preference_context"]) 89 + } 90 + if _, exists := got["preference_context"]; exists { 91 + t.Fatalf("must not log raw preference_context") 92 + } 93 + if v, ok := got["has_topic_weights"].(bool); !ok || !v { 94 + t.Fatalf("expected has_topic_weights=true, got %#v", got["has_topic_weights"]) 95 + } 96 + }
+9
cmd/mistermorph/contactscmd/contacts.go
··· 94 94 var subjectID string 95 95 var contactNickname string 96 96 var personaBrief string 97 + var pronouns string 98 + var timezone string 99 + var preferenceContext string 97 100 var displayName string 98 101 var telegramUsername string 99 102 var telegramNickname string ··· 144 147 Status: parseStatus(status), 145 148 ContactNickname: nickname, 146 149 PersonaBrief: strings.TrimSpace(personaBrief), 150 + Pronouns: strings.TrimSpace(pronouns), 151 + Timezone: strings.TrimSpace(timezone), 152 + PreferenceContext: strings.TrimSpace(preferenceContext), 147 153 PersonaTraits: traitMap, 148 154 SubjectID: strings.TrimSpace(subjectID), 149 155 NodeID: strings.TrimSpace(nodeID), ··· 171 177 cmd.Flags().StringVar(&subjectID, "subject-id", "", "Subject id for human contact") 172 178 cmd.Flags().StringVar(&contactNickname, "contact-nickname", "", "Contact nickname") 173 179 cmd.Flags().StringVar(&personaBrief, "persona-brief", "", "Personality summary for this contact") 180 + cmd.Flags().StringVar(&pronouns, "pronouns", "", "Pronouns for this contact (for example: she/her, they/them)") 181 + cmd.Flags().StringVar(&timezone, "timezone", "", "IANA timezone (for example: Asia/Shanghai, America/New_York)") 182 + cmd.Flags().StringVar(&preferenceContext, "preference-context", "", "Long-form preference/context notes for this contact") 174 183 cmd.Flags().StringVar(&displayName, "display-name", "", "Legacy alias of --contact-nickname") 175 184 cmd.Flags().StringVar(&telegramUsername, "telegram-username", "", "Telegram username for human contact (maps to tg:@<username>)") 176 185 cmd.Flags().StringVar(&telegramNickname, "telegram-nickname", "", "Telegram nickname (fallback for contact_nickname)")
+1
cmd/mistermorph/registry.go
··· 156 156 } 157 157 158 158 if viper.GetBool("tools.contacts.enabled") { 159 + r.Register(builtin.NewContactsUpsertTool(true, statepaths.ContactsDir())) 159 160 r.Register(builtin.NewContactsListTool(true, statepaths.ContactsDir())) 160 161 r.Register(builtin.NewContactsCandidateRankTool(builtin.ContactsCandidateRankToolOptions{ 161 162 Enabled: true,
+27
contacts/file_store.go
··· 19 19 const ( 20 20 candidatesFileVersion = 1 21 21 sessionsFileVersion = 1 22 + contactPronounsMaxLen = 64 23 + contactTZMaxLen = 64 24 + contactPrefMaxLen = 2000 22 25 ) 23 26 24 27 type candidatesFile struct { ··· 604 607 c.ContactID = strings.TrimSpace(c.ContactID) 605 608 c.ContactNickname = strings.TrimSpace(c.ContactNickname) 606 609 c.PersonaBrief = strings.TrimSpace(c.PersonaBrief) 610 + c.Pronouns = clipString(strings.TrimSpace(c.Pronouns), contactPronounsMaxLen) 611 + c.Timezone = normalizeTimezone(strings.TrimSpace(c.Timezone)) 612 + c.PreferenceContext = clipString(strings.TrimSpace(c.PreferenceContext), contactPrefMaxLen) 607 613 c.DisplayName = strings.TrimSpace(c.DisplayName) 608 614 if c.ContactNickname == "" && c.DisplayName != "" { 609 615 c.ContactNickname = c.DisplayName ··· 637 643 c.PersonaTraits = nil 638 644 } 639 645 return c 646 + } 647 + 648 + func clipString(input string, max int) string { 649 + if max <= 0 { 650 + return "" 651 + } 652 + if len(input) <= max { 653 + return input 654 + } 655 + return input[:max] 656 + } 657 + 658 + func normalizeTimezone(raw string) string { 659 + raw = clipString(strings.TrimSpace(raw), contactTZMaxLen) 660 + if raw == "" { 661 + return "" 662 + } 663 + if _, err := time.LoadLocation(raw); err != nil { 664 + return "" 665 + } 666 + return raw 640 667 } 641 668 642 669 func normalizeCandidate(c ShareCandidate, now time.Time) ShareCandidate {
+6
contacts/llm_features.go
··· 229 229 "contact_nickname": contact.ContactNickname, 230 230 "persona_brief": contact.PersonaBrief, 231 231 "persona_traits": contact.PersonaTraits, 232 + "pronouns": contact.Pronouns, 233 + "timezone": contact.Timezone, 234 + "preference_context": contact.PreferenceContext, 232 235 "kind": contact.Kind, 233 236 "subject_id": contact.SubjectID, 234 237 "node_id": contact.NodeID, ··· 269 272 "contact_nickname": contact.ContactNickname, 270 273 "persona_brief": contact.PersonaBrief, 271 274 "persona_traits": contact.PersonaTraits, 275 + "pronouns": contact.Pronouns, 276 + "timezone": contact.Timezone, 277 + "preference_context": contact.PreferenceContext, 272 278 "kind": contact.Kind, 273 279 "subject_id": contact.SubjectID, 274 280 "node_id": contact.NodeID,
+9
contacts/service.go
··· 109 109 if ok && strings.TrimSpace(contact.PersonaBrief) == "" && strings.TrimSpace(existing.PersonaBrief) != "" { 110 110 contact.PersonaBrief = strings.TrimSpace(existing.PersonaBrief) 111 111 } 112 + if ok && strings.TrimSpace(contact.Pronouns) == "" && strings.TrimSpace(existing.Pronouns) != "" { 113 + contact.Pronouns = strings.TrimSpace(existing.Pronouns) 114 + } 115 + if ok && strings.TrimSpace(contact.Timezone) == "" && strings.TrimSpace(existing.Timezone) != "" { 116 + contact.Timezone = strings.TrimSpace(existing.Timezone) 117 + } 118 + if ok && strings.TrimSpace(contact.PreferenceContext) == "" && strings.TrimSpace(existing.PreferenceContext) != "" { 119 + contact.PreferenceContext = strings.TrimSpace(existing.PreferenceContext) 120 + } 112 121 if ok && len(contact.PersonaTraits) == 0 && len(existing.PersonaTraits) > 0 { 113 122 contact.PersonaTraits = map[string]float64{} 114 123 for k, v := range existing.PersonaTraits {
+3
contacts/types.go
··· 30 30 PersonaBrief string `json:"persona_brief,omitempty"` 31 31 PersonaTraits map[string]float64 `json:"persona_traits,omitempty"` 32 32 DisplayName string `json:"display_name,omitempty"` // Legacy alias for contact_nickname. 33 + Pronouns string `json:"pronouns,omitempty"` 34 + Timezone string `json:"timezone,omitempty"` 35 + PreferenceContext string `json:"preference_context,omitempty"` 33 36 SubjectID string `json:"subject_id,omitempty"` 34 37 NodeID string `json:"node_id,omitempty"` 35 38 PeerID string `json:"peer_id,omitempty"`
+39 -39
docs/feat/feat_20260208_workspace_persona_and_contacts_profile.md
··· 182 182 183 183 ### C.6 Tool / Prompt 联动任务 184 184 185 - - [ ] `contacts_list` 文档更新 185 + - [x] `contacts_list` 文档更新 186 186 - 文件:`docs/tools.md` 187 187 - 明确返回字段包含 `pronouns` / `timezone` / `preference_context`。 188 - - [ ] LLM 特征提取输入补充上下文 188 + - [x] LLM 特征提取输入补充上下文 189 189 - 文件:`contacts/llm_features.go` 190 190 - 将 `preference_context` 纳入输入,提升 topic/persona 提取准确性。 191 191 - [ ] 隐私边界 ··· 286 286 287 287 ### PR-3: `contacts` 字段扩展(`pronouns/timezone/preference_context`) 288 288 289 - - [ ] 扩展模型 `contacts/types.go`: 290 - - [ ] 增加 `Pronouns` 字段。 291 - - [ ] 增加 `Timezone` 字段。 292 - - [ ] 增加 `PreferenceContext` 字段。 293 - - [ ] 更新 `contacts/file_store.go`: 294 - - [ ] 在 `normalizeContact(...)` 中新增字段 trim。 295 - - [ ] 对 `preference_context` 增加长度上限。 296 - - [ ] 对 `timezone` 增加合法性校验策略(按开放问题结论实施)。 297 - - [ ] 更新 CLI `cmd/mistermorph/contactscmd/contacts.go`: 298 - - [ ] `contacts upsert` 增加 `--pronouns`。 299 - - [ ] `contacts upsert` 增加 `--timezone`。 300 - - [ ] `contacts upsert` 增加 `--preference-context`。 289 + - [x] 扩展模型 `contacts/types.go`: 290 + - [x] 增加 `Pronouns` 字段。 291 + - [x] 增加 `Timezone` 字段。 292 + - [x] 增加 `PreferenceContext` 字段。 293 + - [x] 更新 `contacts/file_store.go`: 294 + - [x] 在 `normalizeContact(...)` 中新增字段 trim。 295 + - [x] 对 `preference_context` 增加长度上限。 296 + - [x] 对 `timezone` 增加合法性校验策略(按开放问题结论实施)。 297 + - [x] 更新 CLI `cmd/mistermorph/contactscmd/contacts.go`: 298 + - [x] `contacts upsert` 增加 `--pronouns`。 299 + - [x] `contacts upsert` 增加 `--timezone`。 300 + - [x] `contacts upsert` 增加 `--preference-context`。 301 301 - [ ] 可选:增加 `--preference-context-file`。 302 - - [ ] 更新 `contacts/llm_features.go`: 303 - - [ ] 在提取输入 payload 中加入 `preference_context`。 304 - - [ ] 保持输出契约不破坏兼容。 305 - - [ ] 更新文档: 306 - - [ ] `docs/tools.md` 更新 `contacts_list` 返回字段说明。 302 + - [x] 更新 `contacts/llm_features.go`: 303 + - [x] 在提取输入 payload 中加入 `preference_context`。 304 + - [x] 保持输出契约不破坏兼容。 305 + - [x] 更新文档: 306 + - [x] `docs/tools.md` 更新 `contacts_list` 返回字段说明。 307 307 - [ ] 测试: 308 308 - [ ] `contacts` 存储 roundtrip(新字段读写)。 309 309 - [ ] CLI 参数解析与写入测试。 310 310 - [ ] `llm_features` payload 组装测试。 311 - - [ ] 验证: 312 - - [ ] `go test ./contacts/... ./cmd/mistermorph/contactscmd/...` 311 + - [x] 验证: 312 + - [x] `go test ./contacts/... ./cmd/mistermorph/contactscmd/...` 313 313 314 314 ### PR-4: 新增 `contacts_upsert` 内置 Tool 315 315 316 - - [ ] 新增 `tools/builtin/contacts_upsert.go`: 317 - - [ ] 定义工具名、描述、参数 schema。 318 - - [ ] 实现“partial patch”语义(未提供字段保留旧值)。 319 - - [ ] 至少支持 `contact_id|subject_id|node_id|peer_id` 的最小识别策略。 320 - - [ ] 更新 `cmd/mistermorph/registry.go`: 321 - - [ ] 在 `tools.contacts.enabled=true` 时注册 `contacts_upsert`。 322 - - [ ] 更新 `agent/engine_helpers.go`: 323 - - [ ] 为 `contacts_upsert` 增加安全日志摘要(不输出长文本全文)。 324 - - [ ] 更新文档: 325 - - [ ] `docs/tools.md` 增加 `contacts_upsert` 章节(用途、参数、约束)。 326 - - [ ] 测试: 327 - - [ ] 新增 `tools/builtin/contacts_upsert_test.go`。 328 - - [ ] 场景覆盖:创建、更新、partial patch、非法 kind/status、缺失标识字段。 329 - - [ ] 更新 `agent/engine_helpers_test.go`,覆盖新工具参数摘要。 330 - - [ ] 验证: 331 - - [ ] `go test ./tools/builtin/... ./agent/...` 316 + - [x] 新增 `tools/builtin/contacts_upsert.go`: 317 + - [x] 定义工具名、描述、参数 schema。 318 + - [x] 实现“partial patch”语义(未提供字段保留旧值)。 319 + - [x] 至少支持 `contact_id|subject_id` 的最小识别策略。 320 + - [x] 更新 `cmd/mistermorph/registry.go`: 321 + - [x] 在 `tools.contacts.enabled=true` 时注册 `contacts_upsert`。 322 + - [x] 更新 `agent/engine_helpers.go`: 323 + - [x] 为 `contacts_upsert` 增加安全日志摘要(不输出长文本全文)。 324 + - [x] 更新文档: 325 + - [x] `docs/tools.md` 增加 `contacts_upsert` 章节(用途、参数、约束)。 326 + - [x] 测试: 327 + - [x] 新增 `tools/builtin/contacts_upsert_test.go`。 328 + - [x] 场景覆盖:创建、更新、partial patch、非法 kind/status、缺失标识字段。 329 + - [x] 更新 `agent/engine_helpers_test.go`,覆盖新工具参数摘要。 330 + - [x] 验证: 331 + - [x] `go test ./tools/builtin/... ./agent/...` 332 332 333 333 ### 收尾(合并前统一检查) 334 334 335 - - [ ] 全量单测:`go test ./...` 336 - - [ ] 静态检查:`go vet ./...` 335 + - [x] 全量单测:`go test ./...` 336 + - [x] 静态检查:`go vet ./...` 337 337 - [ ] 文档自检: 338 338 - [ ] `docs/tools.md` 与实际注册工具一致。 339 339 - [ ] `docs/prompt.md` 与实际注入路径一致。
+17
docs/tools.md
··· 136 136 | 参数 | 类型 | 必填 | 默认值 | 说明 | 137 137 |---|---|---|---|---| 138 138 | `contact_id` | `string` | 条件必填 | 无 | 联系人稳定 ID。更新时建议提供。 | 139 + | `kind` | `string` | 否 | 保留旧值或存储层默认 | `agent` / `human`。 | 139 140 | `status` | `string` | 否 | `active` | `active` / `inactive`。 | 140 141 | `contact_nickname` | `string` | 否 | 空 | 联系人昵称。 | 141 142 | `persona_brief` | `string` | 否 | 空 | 联系人互动风格摘要。 | 142 143 | `persona_traits` | `object<string,number>` | 否 | 空 | 人格特征权重映射。 | 144 + | `pronouns` | `string` | 否 | 空 | 代词信息(如 `she/her`、`they/them`)。 | 145 + | `timezone` | `string` | 否 | 空 | IANA 时区(如 `Asia/Shanghai`、`America/New_York`)。 | 146 + | `preference_context` | `string` | 否 | 空 | 长文本偏好/上下文备注。 | 143 147 | `subject_id` | `string` | 条件必填 | 空 | 人类联系人的主体 ID。 | 144 148 | `understanding_depth` | `number` | 否 | 继承旧值或 `30` | 认知深度,范围 `[0,100]`。 | 145 149 | `topic_weights` | `object<string,number>` | 否 | 空 | topic 偏好权重映射。 | ··· 149 153 150 154 - `contact_id` / `subject_id` 至少提供一个。 151 155 - 当 `contact_id` 缺失时,服务会尝试由 `subject_id` 推导联系人 ID。 156 + - `kind` 仅支持 `agent|human`。 152 157 - `status` 仅支持 `active|inactive`。 158 + - `timezone` 仅接受合法 IANA 时区;非法值会被规范化为“空”。 153 159 - 数值参数会在存储层被归一化(例如深度裁剪到 `[0,100]`、分值裁剪到 `[0,1]`)。 154 160 155 161 ## `contacts_list` ··· 162 168 |---|---|---|---|---| 163 169 | `status` | `string` | 否 | `all` | `all` / `active` / `inactive`。 | 164 170 | `limit` | `integer` | 否 | `0` | 返回条数上限,`<=0` 表示不限制。 | 171 + 172 + 返回: 173 + 174 + - 返回 JSON 数组,元素为 `Contact` 对象(结构与 `contacts/types.go` 对齐)。 175 + - 关键字段包含: 176 + - `contact_id` / `kind` / `status` 177 + - `contact_nickname` / `persona_brief` / `persona_traits` 178 + - `pronouns` / `timezone` / `preference_context` 179 + - `subject_id` / `node_id` / `peer_id` / `addresses` 180 + - `understanding_depth` / `topic_weights` / `reciprocity_norm` 181 + - `created_at` / `updated_at` 及分享状态相关字段 165 182 166 183 ## `contacts_candidate_rank` 167 184
+338
tools/builtin/contacts_upsert.go
··· 1 + package builtin 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "strconv" 8 + "strings" 9 + "time" 10 + 11 + "github.com/quailyquaily/mistermorph/contacts" 12 + "github.com/quailyquaily/mistermorph/internal/pathutil" 13 + ) 14 + 15 + type ContactsUpsertTool struct { 16 + Enabled bool 17 + ContactsDir string 18 + } 19 + 20 + func NewContactsUpsertTool(enabled bool, contactsDir string) *ContactsUpsertTool { 21 + return &ContactsUpsertTool{ 22 + Enabled: enabled, 23 + ContactsDir: strings.TrimSpace(contactsDir), 24 + } 25 + } 26 + 27 + func (t *ContactsUpsertTool) Name() string { return "contacts_upsert" } 28 + 29 + func (t *ContactsUpsertTool) Description() string { 30 + return "Creates or updates one contact profile with partial-patch semantics (omitted fields are preserved)." 31 + } 32 + 33 + func (t *ContactsUpsertTool) ParameterSchema() string { 34 + s := map[string]any{ 35 + "type": "object", 36 + "properties": map[string]any{ 37 + "contact_id": map[string]any{ 38 + "type": "string", 39 + "description": "Stable contact id. Recommended for updates.", 40 + }, 41 + "kind": map[string]any{ 42 + "type": "string", 43 + "description": "Contact kind: agent|human.", 44 + }, 45 + "status": map[string]any{ 46 + "type": "string", 47 + "description": "Contact status: active|inactive.", 48 + }, 49 + "contact_nickname": map[string]any{ 50 + "type": "string", 51 + "description": "Display nickname for this contact.", 52 + }, 53 + "persona_brief": map[string]any{ 54 + "type": "string", 55 + "description": "Short personality/interaction summary.", 56 + }, 57 + "persona_traits": map[string]any{ 58 + "type": "object", 59 + "additionalProperties": map[string]any{"type": "number"}, 60 + "description": "Trait score map: trait->score in [0,1].", 61 + }, 62 + "pronouns": map[string]any{ 63 + "type": "string", 64 + "description": "Optional pronouns for this contact.", 65 + }, 66 + "timezone": map[string]any{ 67 + "type": "string", 68 + "description": "Optional IANA timezone for this contact.", 69 + }, 70 + "preference_context": map[string]any{ 71 + "type": "string", 72 + "description": "Long-form preference/context notes.", 73 + }, 74 + "subject_id": map[string]any{ 75 + "type": "string", 76 + "description": "Subject id. When contact_id is missing, this can be used to derive contact id.", 77 + }, 78 + "understanding_depth": map[string]any{ 79 + "type": "number", 80 + "description": "Understanding depth in [0,100].", 81 + }, 82 + "topic_weights": map[string]any{ 83 + "type": "object", 84 + "additionalProperties": map[string]any{"type": "number"}, 85 + "description": "Topic affinity map: topic->score in [0,1].", 86 + }, 87 + "reciprocity_norm": map[string]any{ 88 + "type": "number", 89 + "description": "Reciprocity score in [0,1].", 90 + }, 91 + }, 92 + } 93 + b, _ := json.MarshalIndent(s, "", " ") 94 + return string(b) 95 + } 96 + 97 + func (t *ContactsUpsertTool) Execute(ctx context.Context, params map[string]any) (string, error) { 98 + if t == nil || !t.Enabled { 99 + return "", fmt.Errorf("contacts_upsert tool is disabled") 100 + } 101 + contactsDir := pathutil.ExpandHomePath(strings.TrimSpace(t.ContactsDir)) 102 + if contactsDir == "" { 103 + return "", fmt.Errorf("contacts dir is not configured") 104 + } 105 + 106 + contactID, hasContactID := optionalStringParam(params, "contact_id") 107 + subjectID, hasSubjectID := optionalStringParam(params, "subject_id") 108 + if strings.TrimSpace(contactID) == "" && strings.TrimSpace(subjectID) == "" { 109 + return "", fmt.Errorf("contact_id or subject_id is required") 110 + } 111 + 112 + svc := contacts.NewService(contacts.NewFileStore(contactsDir)) 113 + base, found, err := lookupBaseContact(ctx, svc, strings.TrimSpace(contactID), strings.TrimSpace(subjectID)) 114 + if err != nil { 115 + return "", err 116 + } 117 + if !found { 118 + base = contacts.Contact{ 119 + UnderstandingDepth: 30, 120 + ReciprocityNorm: 0.5, 121 + } 122 + if strings.TrimSpace(subjectID) != "" { 123 + base.Kind = contacts.KindHuman 124 + } 125 + } 126 + 127 + if hasContactID { 128 + base.ContactID = strings.TrimSpace(contactID) 129 + } 130 + if hasSubjectID { 131 + base.SubjectID = strings.TrimSpace(subjectID) 132 + } 133 + if value, ok := optionalStringParam(params, "kind"); ok { 134 + kind, err := parseUpsertKind(value) 135 + if err != nil { 136 + return "", err 137 + } 138 + base.Kind = kind 139 + } 140 + if value, ok := optionalStringParam(params, "status"); ok { 141 + status, err := parseUpsertStatus(value) 142 + if err != nil { 143 + return "", err 144 + } 145 + base.Status = status 146 + } 147 + if value, ok := optionalStringParam(params, "contact_nickname"); ok { 148 + base.ContactNickname = value 149 + } 150 + if value, ok := optionalStringParam(params, "persona_brief"); ok { 151 + base.PersonaBrief = value 152 + } 153 + if value, ok := optionalStringParam(params, "pronouns"); ok { 154 + base.Pronouns = value 155 + } 156 + if value, ok := optionalStringParam(params, "timezone"); ok { 157 + base.Timezone = value 158 + } 159 + if value, ok := optionalStringParam(params, "preference_context"); ok { 160 + base.PreferenceContext = value 161 + } 162 + if raw, ok := params["understanding_depth"]; ok { 163 + base.UnderstandingDepth = parseFloatDefault(raw, base.UnderstandingDepth) 164 + } 165 + if raw, ok := params["reciprocity_norm"]; ok { 166 + base.ReciprocityNorm = parseFloatDefault(raw, base.ReciprocityNorm) 167 + } 168 + if raw, ok := params["topic_weights"]; ok { 169 + values, err := parseNumericMap(raw, "topic_weights") 170 + if err != nil { 171 + return "", err 172 + } 173 + base.TopicWeights = values 174 + } 175 + if raw, ok := params["persona_traits"]; ok { 176 + values, err := parseNumericMap(raw, "persona_traits") 177 + if err != nil { 178 + return "", err 179 + } 180 + base.PersonaTraits = values 181 + } 182 + 183 + updated, err := svc.UpsertContact(ctx, base, time.Now().UTC()) 184 + if err != nil { 185 + return "", err 186 + } 187 + 188 + out, _ := json.MarshalIndent(map[string]any{ 189 + "contact": updated, 190 + }, "", " ") 191 + return string(out), nil 192 + } 193 + 194 + func lookupBaseContact(ctx context.Context, svc *contacts.Service, contactID string, subjectID string) (contacts.Contact, bool, error) { 195 + if svc == nil { 196 + return contacts.Contact{}, false, fmt.Errorf("nil contacts service") 197 + } 198 + ids := []string{contactID, subjectID} 199 + seen := map[string]bool{} 200 + for _, id := range ids { 201 + id = strings.TrimSpace(id) 202 + if id == "" || seen[id] { 203 + continue 204 + } 205 + seen[id] = true 206 + item, ok, err := svc.GetContact(ctx, id) 207 + if err != nil { 208 + return contacts.Contact{}, false, err 209 + } 210 + if ok { 211 + return item, true, nil 212 + } 213 + } 214 + return contacts.Contact{}, false, nil 215 + } 216 + 217 + func optionalStringParam(params map[string]any, key string) (string, bool) { 218 + raw, ok := params[key] 219 + if !ok { 220 + return "", false 221 + } 222 + switch v := raw.(type) { 223 + case string: 224 + return strings.TrimSpace(v), true 225 + default: 226 + return strings.TrimSpace(fmt.Sprintf("%v", v)), true 227 + } 228 + } 229 + 230 + func parseUpsertStatus(raw string) (contacts.Status, error) { 231 + value := strings.ToLower(strings.TrimSpace(raw)) 232 + switch value { 233 + case "active": 234 + return contacts.StatusActive, nil 235 + case "inactive": 236 + return contacts.StatusInactive, nil 237 + case "": 238 + return contacts.StatusActive, nil 239 + default: 240 + return "", fmt.Errorf("invalid status %q (want active|inactive)", raw) 241 + } 242 + } 243 + 244 + func parseUpsertKind(raw string) (contacts.Kind, error) { 245 + value := strings.ToLower(strings.TrimSpace(raw)) 246 + switch value { 247 + case "agent": 248 + return contacts.KindAgent, nil 249 + case "human": 250 + return contacts.KindHuman, nil 251 + default: 252 + return "", fmt.Errorf("invalid kind %q (want agent|human)", raw) 253 + } 254 + } 255 + 256 + func parseNumericMap(raw any, fieldName string) (map[string]float64, error) { 257 + switch v := raw.(type) { 258 + case map[string]float64: 259 + if len(v) == 0 { 260 + return nil, nil 261 + } 262 + out := make(map[string]float64, len(v)) 263 + for key, score := range v { 264 + nKey := strings.TrimSpace(key) 265 + if nKey == "" { 266 + continue 267 + } 268 + out[nKey] = score 269 + } 270 + if len(out) == 0 { 271 + return nil, nil 272 + } 273 + return out, nil 274 + case map[string]any: 275 + if len(v) == 0 { 276 + return nil, nil 277 + } 278 + out := make(map[string]float64, len(v)) 279 + for key, rawScore := range v { 280 + nKey := strings.TrimSpace(key) 281 + if nKey == "" { 282 + continue 283 + } 284 + score, err := toFloat64(rawScore) 285 + if err != nil { 286 + return nil, fmt.Errorf("%s[%q]: %w", fieldName, key, err) 287 + } 288 + out[nKey] = score 289 + } 290 + if len(out) == 0 { 291 + return nil, nil 292 + } 293 + return out, nil 294 + default: 295 + return nil, fmt.Errorf("%s must be an object map", fieldName) 296 + } 297 + } 298 + 299 + func toFloat64(raw any) (float64, error) { 300 + switch v := raw.(type) { 301 + case float64: 302 + return v, nil 303 + case float32: 304 + return float64(v), nil 305 + case int: 306 + return float64(v), nil 307 + case int8: 308 + return float64(v), nil 309 + case int16: 310 + return float64(v), nil 311 + case int32: 312 + return float64(v), nil 313 + case int64: 314 + return float64(v), nil 315 + case uint: 316 + return float64(v), nil 317 + case uint8: 318 + return float64(v), nil 319 + case uint16: 320 + return float64(v), nil 321 + case uint32: 322 + return float64(v), nil 323 + case uint64: 324 + return float64(v), nil 325 + case string: 326 + text := strings.TrimSpace(v) 327 + if text == "" { 328 + return 0, fmt.Errorf("empty number") 329 + } 330 + n, err := strconv.ParseFloat(text, 64) 331 + if err != nil { 332 + return 0, fmt.Errorf("invalid number %q", text) 333 + } 334 + return n, nil 335 + default: 336 + return 0, fmt.Errorf("unsupported number type %T", raw) 337 + } 338 + }
+135
tools/builtin/contacts_upsert_test.go
··· 1 + package builtin 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestContactsUpsertTool_CreateBySubjectID(t *testing.T) { 11 + tool := NewContactsUpsertTool(true, t.TempDir()) 12 + out, err := tool.Execute(context.Background(), map[string]any{ 13 + "subject_id": "tg:@alice", 14 + "status": "active", 15 + "contact_nickname": "Alice", 16 + "pronouns": "she/her", 17 + "timezone": "America/New_York", 18 + "preference_context": "prefers concise updates", 19 + "topic_weights": map[string]any{ 20 + "go": 0.8, 21 + "ops": 0.5, 22 + }, 23 + }) 24 + if err != nil { 25 + t.Fatalf("Execute() error = %v", err) 26 + } 27 + got := parseUpsertContact(t, out) 28 + if got.ContactID != "tg:@alice" { 29 + t.Fatalf("contact_id mismatch: got %q", got.ContactID) 30 + } 31 + if got.SubjectID != "tg:@alice" { 32 + t.Fatalf("subject_id mismatch: got %q", got.SubjectID) 33 + } 34 + if got.Kind != "human" { 35 + t.Fatalf("kind mismatch: got %q want human", got.Kind) 36 + } 37 + if got.ContactNickname != "Alice" { 38 + t.Fatalf("contact_nickname mismatch: got %q", got.ContactNickname) 39 + } 40 + if got.Pronouns != "she/her" { 41 + t.Fatalf("pronouns mismatch: got %q", got.Pronouns) 42 + } 43 + if got.Timezone != "America/New_York" { 44 + t.Fatalf("timezone mismatch: got %q", got.Timezone) 45 + } 46 + if got.PreferenceContext != "prefers concise updates" { 47 + t.Fatalf("preference_context mismatch: got %q", got.PreferenceContext) 48 + } 49 + if len(got.TopicWeights) != 2 { 50 + t.Fatalf("topic_weights mismatch: got %#v", got.TopicWeights) 51 + } 52 + } 53 + 54 + func TestContactsUpsertTool_PartialPatchPreservesFields(t *testing.T) { 55 + tool := NewContactsUpsertTool(true, t.TempDir()) 56 + _, err := tool.Execute(context.Background(), map[string]any{ 57 + "subject_id": "tg:@alice", 58 + "persona_brief": "likes deep technical discussion", 59 + "contact_nickname": "Alice", 60 + }) 61 + if err != nil { 62 + t.Fatalf("seed Execute() error = %v", err) 63 + } 64 + 65 + out, err := tool.Execute(context.Background(), map[string]any{ 66 + "contact_id": "tg:@alice", 67 + "contact_nickname": "Alice L", 68 + }) 69 + if err != nil { 70 + t.Fatalf("patch Execute() error = %v", err) 71 + } 72 + got := parseUpsertContact(t, out) 73 + if got.ContactNickname != "Alice L" { 74 + t.Fatalf("contact_nickname mismatch: got %q", got.ContactNickname) 75 + } 76 + if got.PersonaBrief != "likes deep technical discussion" { 77 + t.Fatalf("persona_brief should be preserved, got %q", got.PersonaBrief) 78 + } 79 + } 80 + 81 + func TestContactsUpsertTool_MissingIdentifiers(t *testing.T) { 82 + tool := NewContactsUpsertTool(true, t.TempDir()) 83 + _, err := tool.Execute(context.Background(), map[string]any{ 84 + "status": "active", 85 + }) 86 + if err == nil || !strings.Contains(err.Error(), "contact_id or subject_id is required") { 87 + t.Fatalf("expected missing id error, got %v", err) 88 + } 89 + } 90 + 91 + func TestContactsUpsertTool_InvalidStatus(t *testing.T) { 92 + tool := NewContactsUpsertTool(true, t.TempDir()) 93 + _, err := tool.Execute(context.Background(), map[string]any{ 94 + "subject_id": "tg:@alice", 95 + "status": "invalid", 96 + }) 97 + if err == nil || !strings.Contains(err.Error(), "invalid status") { 98 + t.Fatalf("expected invalid status error, got %v", err) 99 + } 100 + } 101 + 102 + func TestContactsUpsertTool_InvalidKind(t *testing.T) { 103 + tool := NewContactsUpsertTool(true, t.TempDir()) 104 + _, err := tool.Execute(context.Background(), map[string]any{ 105 + "subject_id": "tg:@alice", 106 + "kind": "robot", 107 + }) 108 + if err == nil || !strings.Contains(err.Error(), "invalid kind") { 109 + t.Fatalf("expected invalid kind error, got %v", err) 110 + } 111 + } 112 + 113 + type upsertToolContact struct { 114 + ContactID string `json:"contact_id"` 115 + Kind string `json:"kind"` 116 + Status string `json:"status"` 117 + ContactNickname string `json:"contact_nickname"` 118 + PersonaBrief string `json:"persona_brief"` 119 + Pronouns string `json:"pronouns"` 120 + Timezone string `json:"timezone"` 121 + PreferenceContext string `json:"preference_context"` 122 + SubjectID string `json:"subject_id"` 123 + TopicWeights map[string]float64 `json:"topic_weights"` 124 + } 125 + 126 + func parseUpsertContact(t *testing.T, raw string) upsertToolContact { 127 + t.Helper() 128 + var out struct { 129 + Contact upsertToolContact `json:"contact"` 130 + } 131 + if err := json.Unmarshal([]byte(raw), &out); err != nil { 132 + t.Fatalf("json.Unmarshal() error = %v", err) 133 + } 134 + return out.Contact 135 + }