Skip to content

Commit 920d7e6

Browse files
idoubiclaude
andcommitted
Add agent knowledge base with size-adaptive prompt injection
Owners upload text reference files (validated: allowlisted extensions, UTF-8 only) stored raw in agent_files under knowledge/ with derived search chunks in agent_knowledge_chunks. Prompt assembly is stable across turns: small corpora (≤24k chars) inject in full with per-file [K#] citation sources the web UI renders as clickable badges; larger corpora inject pinned KNOWLEDGE.md plus a file index and route retrieval through the new knowledge_search tool (keyword scoring with CJK bigrams, owner fallback for shared agents). Fork now copies and reindexes the corpus instead of indexing identity files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2833ab5 commit 920d7e6

27 files changed

Lines changed: 2072 additions & 53 deletions

internal/agent/context_chatbot_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package agent
22

33
import (
44
"context"
5+
"sort"
56
"strings"
67
"testing"
78

89
"github.com/fastclaw-ai/fastclaw/internal/config"
10+
"github.com/fastclaw-ai/fastclaw/internal/store"
911
)
1012

1113
// fakeMemoryStore is a deterministic in-memory MemoryStore for the
@@ -61,6 +63,25 @@ func (f *fakeMemoryStore) SaveWorkspaceFile(ctx context.Context, agentID, userID
6163
return nil
6264
}
6365

66+
// ListKnowledgeDocs makes the fake satisfy knowledgeDocLister so the
67+
// prompt's knowledge section renders from uploaded knowledge/* rows.
68+
// Sorted for deterministic [K#] ordering, matching the DBStore query.
69+
func (f *fakeMemoryStore) ListKnowledgeDocs(ctx context.Context, agentID, userID string) ([]store.KnowledgeDoc, error) {
70+
prefix := agentID + "|" + userID + "|knowledge/"
71+
var out []store.KnowledgeDoc
72+
for key, data := range f.files {
73+
if !strings.HasPrefix(key, prefix) {
74+
continue
75+
}
76+
out = append(out, store.KnowledgeDoc{
77+
Path: strings.TrimPrefix(key, agentID+"|"+userID+"|"),
78+
Content: string(data),
79+
})
80+
}
81+
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
82+
return out, nil
83+
}
84+
6485
const (
6586
testAgentID = "agt_test"
6687
ownerUID = "u_owner"
@@ -112,6 +133,7 @@ func TestChatbotPrompt_EmptyChatter(t *testing.T) {
112133
func TestChatbotPrompt_PopulatedChatter(t *testing.T) {
113134
store := newFakeMemoryStore()
114135
store.put(testAgentID, ownerUID, "SOUL.md", "# DTJ Soul")
136+
store.put(testAgentID, ownerUID, "knowledge/faq.md", "# FAQ\n- Plan: Pro includes web search")
115137
store.put(testAgentID, chatterUID, "USER.md", "# Current Chatter\n- Name: 品冠")
116138
store.put(testAgentID, chatterUID, "MEMORY.md", "# Memory Log\n- 用户在做产品")
117139
cb := newChatbotBuilder(store)
@@ -128,6 +150,66 @@ func TestChatbotPrompt_PopulatedChatter(t *testing.T) {
128150
mustContain(t, prompt, "用户在做产品")
129151
mustContain(t, prompt, "Treat as factual and current")
130152
mustNotContain(t, prompt, "(empty — nothing recorded yet for this chatter")
153+
154+
// Agent knowledge is owner-uploaded reference material, distinct
155+
// from chatter memory and persona files. A small corpus is injected
156+
// in full — no query dependence, stable across turns.
157+
mustContain(t, prompt, `<agent_knowledge_base mode="full">`)
158+
mustContain(t, prompt, "source_id: K1")
159+
mustContain(t, prompt, "file: faq.md")
160+
mustContain(t, prompt, "path: knowledge/faq.md")
161+
mustContain(t, prompt, "cite it inline with the source_id")
162+
mustContain(t, prompt, "Plan: Pro includes web search")
163+
}
164+
165+
func TestChatbotPrompt_KnowledgeSourceIDsFollowUploadOrder(t *testing.T) {
166+
store := newFakeMemoryStore()
167+
store.put(testAgentID, ownerUID, "KNOWLEDGE.md", "pinned owner notes")
168+
store.put(testAgentID, ownerUID, "knowledge/aaa-faq.md", "faq body")
169+
store.put(testAgentID, ownerUID, "knowledge/bbb-pricing.md", "pricing body")
170+
cb := newChatbotBuilder(store)
171+
172+
prompt := cb.BuildSystemPromptAs(chatterUID, cb.memory.WithUserID(chatterUID))
173+
174+
// Pinned KNOWLEDGE.md is K1, uploaded files follow in path order.
175+
mustContain(t, prompt, "## [K1] KNOWLEDGE.md")
176+
mustContain(t, prompt, "pinned owner notes")
177+
mustContain(t, prompt, "source_id: K2\nfile: aaa-faq.md\npath: knowledge/aaa-faq.md")
178+
mustContain(t, prompt, "source_id: K3\nfile: bbb-pricing.md\npath: knowledge/bbb-pricing.md")
179+
180+
// And the extraction side agrees with what was injected — this pair
181+
// is the contract the web citation badges depend on.
182+
sources := extractKnowledgeCitationSources(prompt)
183+
if len(sources) != 3 {
184+
t.Fatalf("extracted %d sources, want 3: %+v", len(sources), sources)
185+
}
186+
if sources[1].ID != "K2" || sources[1].File != "aaa-faq.md" || sources[1].Path != "knowledge/aaa-faq.md" {
187+
t.Fatalf("unexpected source[1]: %+v", sources[1])
188+
}
189+
}
190+
191+
func TestChatbotPrompt_LargeKnowledgeSwitchesToIndexMode(t *testing.T) {
192+
store := newFakeMemoryStore()
193+
big := strings.Repeat("知识库内容 knowledge body. ", 2000) // ~48k chars, over the full-inject budget
194+
store.put(testAgentID, ownerUID, "knowledge/aaa-handbook.md", big)
195+
store.put(testAgentID, ownerUID, "knowledge/bbb-faq.md", "small faq body")
196+
cb := newChatbotBuilder(store)
197+
198+
prompt := cb.BuildSystemPromptAs(chatterUID, cb.memory.WithUserID(chatterUID))
199+
200+
mustContain(t, prompt, `<agent_knowledge_base mode="index">`)
201+
mustContain(t, prompt, "knowledge_search")
202+
mustContain(t, prompt, "- aaa-handbook.md")
203+
mustContain(t, prompt, "- bbb-faq.md")
204+
// The corpus body must NOT be inlined in index mode.
205+
mustNotContain(t, prompt, "small faq body")
206+
if len(prompt) > 20000+len(big)/10 {
207+
t.Fatalf("index-mode prompt looks like it inlined the corpus (len=%d)", len(prompt))
208+
}
209+
// No [K#] source blocks in index mode → no citation metadata.
210+
if sources := extractKnowledgeCitationSources(prompt); len(sources) != 0 {
211+
t.Fatalf("index mode should not emit citation sources, got %+v", sources)
212+
}
131213
}
132214

133215
func TestChatbotPrompt_NoMemorySearchEscapeHatch(t *testing.T) {

internal/agent/knowledge.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/fastclaw-ai/fastclaw/internal/config"
9+
"github.com/fastclaw-ai/fastclaw/internal/store"
10+
)
11+
12+
// The knowledge base is owner-curated reference material: the pinned
13+
// KNOWLEDGE.md (edited as a system file) plus files uploaded under the
14+
// knowledge/ prefix. This module renders it into a prompt section that
15+
// depends only on the corpus itself — never on the current message — so
16+
// the system prompt stays stable across turns (prompt-cache friendly)
17+
// and follow-up questions keep the same context.
18+
//
19+
// Two shapes, picked by corpus size:
20+
//
21+
// full — the whole corpus fits the budget: every file is injected
22+
// verbatim with a per-file source id ([K1], [K2], …) the model
23+
// cites inline; the web UI turns those ids into clickable
24+
// source badges (see knowledge_citations.go).
25+
// index — corpus too large to inline: the pinned KNOWLEDGE.md (capped)
26+
// plus a file index is injected, and the model is pointed at
27+
// the knowledge_search tool for on-demand retrieval.
28+
const (
29+
// knowledgeFullInjectMaxChars is the total corpus size (runes) at or
30+
// below which every knowledge file is injected verbatim. Small corpora
31+
// skip retrieval entirely — 100% recall, no missed-lookup failure mode.
32+
knowledgeFullInjectMaxChars = 24000
33+
// knowledgePinnedMaxChars caps the always-injected KNOWLEDGE.md in
34+
// index mode so a runaway pinned note can't crowd out the prompt.
35+
knowledgePinnedMaxChars = 8000
36+
)
37+
38+
// knowledgeDocLister is the optional MemoryStore capability exposing the
39+
// uploaded corpus. MemoryStoreAdapter implements it; the legacy filesystem
40+
// setup doesn't, in which case only the pinned KNOWLEDGE.md applies.
41+
type knowledgeDocLister interface {
42+
ListKnowledgeDocs(ctx context.Context, agentID, userID string) ([]store.KnowledgeDoc, error)
43+
}
44+
45+
func modKnowledge(p *promptCtx) string {
46+
return p.cb.buildKnowledgeSection()
47+
}
48+
49+
func (cb *ContextBuilder) buildKnowledgeSection() string {
50+
pinned := cb.loadFileForUser("KNOWLEDGE.md", cb.userID)
51+
var docs []store.KnowledgeDoc
52+
if lister, ok := cb.store.(knowledgeDocLister); ok {
53+
ctx := context.Background()
54+
if cb.userID != "" {
55+
ctx = config.WithUserID(ctx, cb.userID)
56+
}
57+
if got, err := lister.ListKnowledgeDocs(ctx, cb.agentID, cb.userID); err == nil {
58+
docs = got
59+
}
60+
}
61+
if pinned == "" && len(docs) == 0 {
62+
return ""
63+
}
64+
total := len([]rune(pinned))
65+
for _, doc := range docs {
66+
total += len([]rune(doc.Content))
67+
}
68+
if total <= knowledgeFullInjectMaxChars {
69+
return knowledgeFullSection(pinned, docs)
70+
}
71+
return knowledgeIndexSection(pinned, docs)
72+
}
73+
74+
// knowledgeFullSection injects the entire corpus, one source block per
75+
// file. The source_id/file/path lines are parsed back out by
76+
// extractKnowledgeCitationSources so [K#] citations in the reply can be
77+
// resolved to their file.
78+
func knowledgeFullSection(pinned string, docs []store.KnowledgeDoc) string {
79+
var blocks []string
80+
id := 0
81+
addBlock := func(file, path, content string) {
82+
id++
83+
blocks = append(blocks, fmt.Sprintf("## [K%d] %s\nsource_id: K%d\nfile: %s\npath: %s\n\n%s",
84+
id, file, id, file, path, strings.TrimSpace(content)))
85+
}
86+
if pinned != "" {
87+
addBlock("KNOWLEDGE.md", "KNOWLEDGE.md", pinned)
88+
}
89+
for _, doc := range docs {
90+
if strings.TrimSpace(doc.Content) == "" {
91+
continue
92+
}
93+
addBlock(knowledgeDocDisplayName(doc.Path), doc.Path, doc.Content)
94+
}
95+
if len(blocks) == 0 {
96+
return ""
97+
}
98+
return "<agent_knowledge_base mode=\"full\">\n" +
99+
"Reference files curated by the agent owner. Treat them as factual, current, and authoritative source material. " +
100+
"Every file has a source_id like K1. When you use a fact from a file, cite it inline with the source_id, e.g. [K1]; " +
101+
"if several files support a point, cite all of them, e.g. [K1][K3]. " +
102+
"If the chatter's question is not covered by these files or the conversation, say what is unknown instead of inventing details.\n\n" +
103+
strings.Join(blocks, "\n\n---\n\n") +
104+
"\n</agent_knowledge_base>"
105+
}
106+
107+
// knowledgeIndexSection lists the corpus without inlining it and points
108+
// the model at the knowledge_search tool.
109+
func knowledgeIndexSection(pinned string, docs []store.KnowledgeDoc) string {
110+
var sb strings.Builder
111+
sb.WriteString("<agent_knowledge_base mode=\"index\">\n")
112+
sb.WriteString(fmt.Sprintf(
113+
"The agent owner uploaded %d knowledge files — too large to include in full, so only an index is shown. "+
114+
"Whenever the chatter asks something these files may cover, call the knowledge_search tool with focused keywords "+
115+
"(retry with different terms if the first search misses) and answer from the returned snippets, mentioning the "+
116+
"source file name. If the files don't cover the question, say what is unknown instead of inventing details.\n",
117+
len(docs)))
118+
if pinned != "" {
119+
if runes := []rune(pinned); len(runes) > knowledgePinnedMaxChars {
120+
pinned = string(runes[:knowledgePinnedMaxChars]) + "\n[KNOWLEDGE.md truncated — use knowledge_search for the rest]"
121+
}
122+
sb.WriteString("\n## Pinned notes (KNOWLEDGE.md)\n")
123+
sb.WriteString(pinned)
124+
sb.WriteString("\n")
125+
}
126+
sb.WriteString("\n## Files\n")
127+
for _, doc := range docs {
128+
sb.WriteString(fmt.Sprintf("- %s (%.1f KB)\n", knowledgeDocDisplayName(doc.Path), float64(len(doc.Content))/1024))
129+
}
130+
sb.WriteString("</agent_knowledge_base>")
131+
return sb.String()
132+
}
133+
134+
// knowledgeDocDisplayName strips the knowledge/ prefix and the
135+
// 12-hex-hash filename prefix the upload handler adds for dedup, giving
136+
// back the name the owner uploaded. Mirrors setup.knowledgeDisplayName.
137+
func knowledgeDocDisplayName(path string) string {
138+
name := strings.TrimPrefix(path, "knowledge/")
139+
if len(name) > 13 && name[12] == '-' && isHexString(name[:12]) {
140+
return name[13:]
141+
}
142+
return name
143+
}
144+
145+
func isHexString(s string) bool {
146+
for _, r := range s {
147+
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
148+
return false
149+
}
150+
}
151+
return true
152+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package agent
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/fastclaw-ai/fastclaw/internal/provider"
8+
)
9+
10+
// knowledgeCitationSource is one [K#]-citable knowledge file, parsed back
11+
// out of the full-mode <agent_knowledge_base> section of the system
12+
// prompt (see knowledgeFullSection). Attached to assistant messages as
13+
// metadata so the web UI can render [K#] citations as clickable badges
14+
// that open the source file.
15+
type knowledgeCitationSource struct {
16+
ID string `json:"id"`
17+
File string `json:"file"`
18+
Path string `json:"path"`
19+
}
20+
21+
var knowledgeSourceBlockRe = regexp.MustCompile(`source_id:\s*(K\d+)\s*\nfile:\s*([^\n]+)\s*\npath:\s*([^\n]+)`)
22+
23+
func extractKnowledgeCitationSources(systemPrompt string) []knowledgeCitationSource {
24+
matches := knowledgeSourceBlockRe.FindAllStringSubmatch(systemPrompt, -1)
25+
if len(matches) == 0 {
26+
return nil
27+
}
28+
out := make([]knowledgeCitationSource, 0, len(matches))
29+
for _, m := range matches {
30+
out = append(out, knowledgeCitationSource{
31+
ID: strings.TrimSpace(m[1]),
32+
File: strings.TrimSpace(m[2]),
33+
Path: strings.TrimSpace(m[3]),
34+
})
35+
}
36+
return out
37+
}
38+
39+
func knowledgeMetadata(sources []knowledgeCitationSource) map[string]any {
40+
if len(sources) == 0 {
41+
return nil
42+
}
43+
return map[string]any{"knowledgeSources": sources}
44+
}
45+
46+
func mergeMetadata(base map[string]any, extra map[string]any) map[string]any {
47+
if len(base) == 0 && len(extra) == 0 {
48+
return nil
49+
}
50+
out := make(map[string]any, len(base)+len(extra))
51+
for k, v := range base {
52+
out[k] = v
53+
}
54+
for k, v := range extra {
55+
out[k] = v
56+
}
57+
return out
58+
}
59+
60+
func firstSystemContent(messages []provider.Message) string {
61+
for _, msg := range messages {
62+
if msg.Role == "system" {
63+
return msg.Content
64+
}
65+
}
66+
return ""
67+
}

0 commit comments

Comments
 (0)