From aaa8df612769c129343b7567ca33ccfa1abc2ba5 Mon Sep 17 00:00:00 2001 From: limin Date: Thu, 29 Oct 2026 19:54:10 +0800 Subject: [PATCH 1/6] feat(tools): add knowledge_search over the shared knowledge base The agent could only reach its own long-term memory (search_answer / search_guide / search_code / search_in_memory). Documents the user explicitly uploaded into the shared knowledge base were unreachable. Add a dedicated knowledge_search tool that queries the KnowledgeStore where Manual=true, and keep the two surfaces disjoint: - knowledge_search.go: new tool over KnowledgeStore.SearchDocuments, scoped to Manual=true via the existing KnowledgeFilter. - search.go: excludeManualKnowledgeDocs drops Manual=true documents from the agent's own memory search, so user-curated documents are reachable only through knowledge_search. - registry.go: tool name, type mapping, definition, and message type. The search_* definitions now state that they cover only agent-authored content and point at knowledge_search for user-uploaded documents. - tools.go: assemble the tool for both the agent-orchestrated and the direct (useAgents=false) assistant paths. - assistant.tmpl / templates.go / assistant.go: render the tool name and state the mandatory routing rule (FIRST retrieval action for a task that depends on user-provided documents). The knowledge package no longer imports graph/subscriptions: the publisher surface it needs is declared locally as knowledge.KnowledgePublisher, which breaks the tools -> knowledge -> subscriptions -> converter -> tester -> tools import cycle. router.go adapts the subscriptions publisher to it. Verified end to end: the agent calls knowledge_search and returns the document content from the knowledge base. --- backend/pkg/database/knowledge/knowledge.go | 15 +- backend/pkg/providers/assistant.go | 1 + backend/pkg/server/router.go | 4 +- backend/pkg/templates/prompts/assistant.tmpl | 10 ++ backend/pkg/templates/templates.go | 1 + backend/pkg/tools/args.go | 6 + backend/pkg/tools/knowledge_search.go | 170 +++++++++++++++++++ backend/pkg/tools/registry.go | 32 +++- backend/pkg/tools/search.go | 42 +++++ backend/pkg/tools/tools.go | 31 ++++ backend/pkg/tools/tools_test.go | 6 +- 11 files changed, 308 insertions(+), 10 deletions(-) create mode 100644 backend/pkg/tools/knowledge_search.go diff --git a/backend/pkg/database/knowledge/knowledge.go b/backend/pkg/database/knowledge/knowledge.go index da20adb11..6b613ec06 100644 --- a/backend/pkg/database/knowledge/knowledge.go +++ b/backend/pkg/database/knowledge/knowledge.go @@ -12,7 +12,6 @@ import ( "pentagi/pkg/database" "pentagi/pkg/graph/model" - "pentagi/pkg/graph/subscriptions" "pentagi/pkg/providers/embeddings" "github.com/google/uuid" @@ -28,9 +27,19 @@ const ( var ErrInvalidDocument = errors.New("invalid knowledge document") +// KnowledgePublisher is the minimal publisher surface the knowledge store needs to +// announce document lifecycle events. It is declared in this package (instead of +// reusing graph/subscriptions.KnowledgePublisher) so that pkg/database/knowledge +// does not import pkg/graph/subscriptions — that import would create a cycle +// (tools -> knowledge -> subscriptions -> converter -> tester -> tools). +type KnowledgePublisher interface { + KnowledgeDocumentCreated(ctx context.Context, doc *model.KnowledgeDocument) + KnowledgeDocumentUpdated(ctx context.Context, doc *model.KnowledgeDocument) + KnowledgeDocumentDeleted(ctx context.Context, doc *model.KnowledgeDocument) +} + // PublisherFactory creates a per-user KnowledgePublisher. -// Matches subscriptions.SubscriptionsController.NewKnowledgePublisher signature. -type PublisherFactory func(userID int64) subscriptions.KnowledgePublisher +type PublisherFactory func(userID int64) KnowledgePublisher // KnowledgeStore covers all GraphQL knowledge operations: // - Admin read methods have no user_id filtering. diff --git a/backend/pkg/providers/assistant.go b/backend/pkg/providers/assistant.go index b6c52f776..631761d47 100644 --- a/backend/pkg/providers/assistant.go +++ b/backend/pkg/providers/assistant.go @@ -343,6 +343,7 @@ func (ap *assistantProvider) getAssistantSystemPrompt(ctx context.Context) (stri "SearchGuideToolName": tools.SearchGuideToolName, "SearchAnswerToolName": tools.SearchAnswerToolName, "SearchCodeToolName": tools.SearchCodeToolName, + "KnowledgeSearchToolName": tools.KnowledgeSearchToolName, "SummarizationToolName": cast.SummarizationToolName, "SummarizedContentPrefix": strings.ReplaceAll(csum.SummarizedContentPrefix, "\n", "\\n"), "UseAgents": useAgents, diff --git a/backend/pkg/server/router.go b/backend/pkg/server/router.go index aa02e2af7..17b3be384 100644 --- a/backend/pkg/server/router.go +++ b/backend/pkg/server/router.go @@ -172,7 +172,9 @@ func NewRouter( logrus.WithError(err).Warn("failed to initialise pgvector store for knowledge API; embedding operations will be unavailable") } } - var knowledgeStore = knowledge.NewKnowledgeStore(db, pgStore, embedder, subscriptions.NewKnowledgePublisher, cfg.EmbeddingMaxTextBytes) + var knowledgeStore = knowledge.NewKnowledgeStore(db, pgStore, embedder, + func(userID int64) knowledge.KnowledgePublisher { return subscriptions.NewKnowledgePublisher(userID) }, + cfg.EmbeddingMaxTextBytes) // ---- Anonymizer replacer ------------------------------------------------ // Shared singleton used by the GraphQL anonymizeText mutation. diff --git a/backend/pkg/templates/prompts/assistant.tmpl b/backend/pkg/templates/prompts/assistant.tmpl index 415a76c7d..8617ba793 100644 --- a/backend/pkg/templates/prompts/assistant.tmpl +++ b/backend/pkg/templates/prompts/assistant.tmpl @@ -31,6 +31,14 @@ You are the user-facing voice of the engagement — the only agent in this syste External search engines and the vector store are indexed in English and shared across all engagements regardless of their working language: any non-English query retrieves nothing. Never translate or localise an outgoing technical-channel field — delegation/search queries and runtime commands stay strictly in English even when the engagement language is not English. +## SHARED KNOWLEDGE BASE — MANDATORY TOOL ROUTING + +The user can upload curated documents (procedures, facts, policies, reference material) into a SHARED KNOWLEDGE BASE that is SEPARATE from your own long-term memory. Retrieving from it requires a SPECIFIC tool, and using the wrong one silently fails: + +- To read the user's uploaded knowledge base → call `{{.KnowledgeSearchToolName}}`. This is the ONLY tool that searches user-uploaded documents. +- The tools `{{.SearchInMemoryToolName}}`, `{{.SearchGuideToolName}}`, `{{.SearchAnswerToolName}}`, `{{.SearchCodeToolName}}` search ONLY content YOU (the agent) generated in past work. They will NEVER return user-uploaded documents. Do NOT use them when the task depends on information that may reside in the user's knowledge base. +- RULE: If the user's request mentions, or may depend on, documents/information the user provided (knowledge base, uploaded docs, reference material, policies, procedures), your FIRST retrieval action MUST be `{{.KnowledgeSearchToolName}}`. Never substitute `{{.SearchAnswerToolName}}` / `{{.SearchGuideToolName}}` / `{{.SearchCodeToolName}}` / `{{.SearchInMemoryToolName}}` for it — they cannot find user-uploaded content. + ## CORE CAPABILITIES / KNOWLEDGE BASE - Expert in security assessment, vulnerability analysis, and penetration testing techniques @@ -164,6 +172,7 @@ External search engines and the vector store are indexed in English and shared a {{- else}} - Web search: `{{.WebSearchToolName}}` — one unified tool. Provide a `query` and a `mode` (`links`, `answer`, `research`, or `exploit`); it selects the best available engine, retries transient failures, and falls back automatically, so you never name a specific engine - Memory search (read-only): `{{.SearchInMemoryToolName}}`, `{{.SearchGuideToolName}}`, `{{.SearchAnswerToolName}}`, `{{.SearchCodeToolName}}` for retrieving stored knowledge from past engagements +- Knowledge base search (read-only): `{{.KnowledgeSearchToolName}}` for retrieving curated documents that the USER uploaded into the shared knowledge base (procedures, facts, policies, reference material). This is SEPARATE from the team's memory/guides/answers above — use `{{.KnowledgeSearchToolName}}` whenever the task depends on information the user explicitly provided in the knowledge base, and do NOT rely on `{{.SearchGuideToolName}}`/`{{.SearchAnswerToolName}}` for that purpose {{- end}} @@ -185,6 +194,7 @@ External search engines and the vector store are indexed in English and shared a - Determine if the user request can be answered directly without tool usage - If tools are needed, identify the minimum necessary tools to complete the task - For complex requests, break down into manageable steps + - **Shared knowledge base retrieval (MANDATORY):** If the user's request depends on information that may reside in the SHARED KNOWLEDGE BASE — curated documents the user uploaded (procedures, facts, policies, reference material) — your FIRST retrieval action MUST be `{{.KnowledgeSearchToolName}}`. The `search_guide` / `search_answer` / `search_in_memory` / `search_code` tools search ONLY the agent's own generated content (guides, answers, code, past work) and will NOT find user-uploaded documents; never use them as a substitute for `{{.KnowledgeSearchToolName}}` when the task is about the user's knowledge base. 2. **Task Execution** - Execute necessary tool calls to gather information or perform actions diff --git a/backend/pkg/templates/templates.go b/backend/pkg/templates/templates.go index 09e1da33b..efef4db09 100644 --- a/backend/pkg/templates/templates.go +++ b/backend/pkg/templates/templates.go @@ -105,6 +105,7 @@ var PromptVariables = map[PromptType][]string{ "SearchGuideToolName", "SearchAnswerToolName", "SearchCodeToolName", + "KnowledgeSearchToolName", "SummarizationToolName", "SummarizedContentPrefix", "UseAgents", diff --git a/backend/pkg/tools/args.go b/backend/pkg/tools/args.go index 669a4acd5..26a42aecd 100644 --- a/backend/pkg/tools/args.go +++ b/backend/pkg/tools/args.go @@ -315,6 +315,12 @@ type StoreCodeAction struct { Message string `json:"message" jsonschema:"required,title=Store code message" jsonschema_description:"Engagement-log entry — a 1-2 short sentence running commentary summarizing the code sample. Written in the engagement language declared by your system prompt."` } +type KnowledgeSearchAction struct { + Query string `json:"query" jsonschema:"required,title=Knowledge base query" jsonschema_description:"Semantic search query for the SHARED KNOWLEDGE BASE — the curated documents that users explicitly uploaded (procedures, facts, policies, reference material). This is separate from the agent's long-term memory. ALWAYS write the query in English because the knowledge base is indexed in English. Use this whenever the task depends on information that may have been stored in the knowledge base; it returns the most relevant documents with their full content."` + Limit int `json:"limit,omitempty" jsonschema:"title=Max results" jsonschema_description:"Maximum number of documents to return. Default 5."` + Message string `json:"message" jsonschema:"required,title=Knowledge search message" jsonschema_description:"Engagement-log entry — a 1-2 sentence commentary on the query. Written in the engagement language declared by your system prompt."` +} + type MaintenanceAction struct { Question string `json:"question" jsonschema:"required" jsonschema_description:"Technical-channel payload — task description for the DevOps team member to maintain the local environment and tools inside the docker container. Always written in English; never translated."` Message string `json:"message" jsonschema:"required,title=Maintenance task message" jsonschema_description:"Engagement-log entry — a 1-2 short sentence running commentary summarizing the maintenance task. Written in the engagement language declared by your system prompt."` diff --git a/backend/pkg/tools/knowledge_search.go b/backend/pkg/tools/knowledge_search.go new file mode 100644 index 000000000..54a7d9018 --- /dev/null +++ b/backend/pkg/tools/knowledge_search.go @@ -0,0 +1,170 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "pentagi/pkg/database" + "pentagi/pkg/database/knowledge" + "pentagi/pkg/database/knowledge/limits" + "pentagi/pkg/graph/model" + obs "pentagi/pkg/observability" + "pentagi/pkg/observability/langfuse" + "pentagi/pkg/providers/embeddings" + + "github.com/sirupsen/logrus" + "github.com/vxcontrol/cloud/anonymizer" + "github.com/vxcontrol/langchaingo/vectorstores/pgvector" +) + +const ( + knowledgeSearchDefaultLimit = 5 +) + +type knowledgeSearch struct { + userID int64 + flowID int64 + taskID *int64 + subtaskID *int64 + replacer anonymizer.Replacer + store *pgvector.Store + embedder embeddings.Embedder + db database.Querier + maxEmbeddingBytes int + vslp VectorStoreLogProvider + knp KnowledgeProvider +} + +// NewKnowledgeSearchTool builds a tool that retrieves documents from the SHARED +// KNOWLEDGE BASE (the curated documents users uploaded) via semantic search. It +// is distinct from the agent memory/answer/guide/code vector stores: those hold +// what the agent produced during work, while the knowledge base holds what users +// explicitly added. The KnowledgeStore is constructed from the same db/store/embedder +// the executor already holds. +func NewKnowledgeSearchTool( + userID int64, + flowID int64, + taskID, subtaskID *int64, + replacer anonymizer.Replacer, + store *pgvector.Store, + embedder embeddings.Embedder, + db database.Querier, + maxEmbeddingBytes int, + vslp VectorStoreLogProvider, + knp KnowledgeProvider, +) Tool { + return &knowledgeSearch{ + userID: userID, + flowID: flowID, + taskID: taskID, + subtaskID: subtaskID, + replacer: replacer, + store: store, + embedder: embedder, + db: db, + maxEmbeddingBytes: maxEmbeddingBytes, + vslp: vslp, + knp: knp, + } +} + +func (ks *knowledgeSearch) Handle(ctx context.Context, name string, args json.RawMessage) (string, error) { + ctx, observation := obs.Observer.NewObservation(ctx) + logger := logrus.WithContext(ctx).WithFields(enrichLogrusFields(ks.flowID, ks.taskID, ks.subtaskID, logrus.Fields{ + "tool": name, + "args": string(args), + })) + + // NOTE: deliberately mirrors the sibling store-backed tools (memory/guide/ + // search/code), which gate only on `store != nil`. The embedder is required at + // search time, but its IsAvailable() helper is a thin `e.Embedder != nil` probe; + // gating on it here would silently drop this tool from the assistant's tool list + // even when the store is fully functional. As long as the store is set the + // executor's embedder is guaranteed non-nil (SetEmbedder sets both together). + if ks.store == nil || ks.embedder == nil { + logger.Error("knowledge store or embedder is not available") + return "", fmt.Errorf("knowledge store or embedder is not available") + } + + switch name { + case KnowledgeSearchToolName: + logger.Infof("MARKER_KNOWLEDGE_SEARCH_CALLED") + var action KnowledgeSearchAction + if err := json.Unmarshal(args, &action); err != nil { + logger.WithError(err).Error("failed to unmarshal knowledge search action arguments") + return "", fmt.Errorf("failed to unmarshal %s knowledge search action arguments: %w", name, err) + } + + limit := action.Limit + if limit <= 0 { + limit = knowledgeSearchDefaultLimit + } + + // Scope to the user's CURATED knowledge base only (Manual = true): those are + // the documents users explicitly uploaded, as opposed to the agent's own + // generated answers/guides/code (Manual = false), which the search_answer / + // search_guide / search_code tools already cover. Scoping here (Go-side + // applyGoFilters) is safe regardless of whether agent docs carry the manual + // metadata key at all. + manual := true + filter := &model.KnowledgeFilter{Manual: &manual} + + store := knowledge.NewKnowledgeStore(ks.db, ks.store, ks.embedder, nil, ks.maxEmbeddingBytes) + + results, err := store.SearchDocuments(ctx, action.Query, filter, limit) + if err != nil { + obs.LogErrorOrCancel(logger, err, "failed to search knowledge base") + return "", fmt.Errorf("failed to search knowledge base: %w", err) + } + + if len(results) == 0 { + observation.Event( + langfuse.WithEventName("search knowledge base"), + langfuse.WithEventStatus("no documents found"), + langfuse.WithEventInput(action.Query), + langfuse.WithEventOutput([]any{}), + ) + return "nothing found in the knowledge base for this query", nil + } + + buffer := strings.Builder{} + for i, res := range results { + doc := res.Document + fmt.Fprintf(&buffer, "# Document %d (relevance score: %.4f)\n\n", i+1, res.Score) + if doc.Question != "" { + fmt.Fprintf(&buffer, "## Question\n%s\n\n", doc.Question) + } + buffer.WriteString("## Content\n\n") + buffer.WriteString(doc.Content) + buffer.WriteString("\n\n") + } + + anonymizedQuery := limits.TruncateToLimit(ks.replacer.ReplaceString(action.Query), limits.MaxQuestionLen) + if agentCtx, ok := GetAgentContext(ctx); ok { + data, _ := json.Marshal(map[string]any{"limit": limit}) + _, _ = ks.vslp.PutLog( + ctx, + agentCtx.ParentAgentType, + agentCtx.CurrentAgentType, + data, + anonymizedQuery, + database.VecstoreActionTypeRetrieve, + buffer.String(), + ks.taskID, + ks.subtaskID, + ) + } + + return buffer.String(), nil + + default: + logger.Error("unknown tool") + return "", fmt.Errorf("unknown tool: %s", name) + } +} + +func (ks *knowledgeSearch) IsAvailable() bool { + return ks.store != nil && ks.embedder != nil +} diff --git a/backend/pkg/tools/registry.go b/backend/pkg/tools/registry.go index 70f5adc65..2d5b1943b 100644 --- a/backend/pkg/tools/registry.go +++ b/backend/pkg/tools/registry.go @@ -51,6 +51,7 @@ const ( SubmitFlowInputToolName = "submit_flow_input" PatchFlowSubtasksToolName = "patch_flow_subtasks" WaitFlowCompletionToolName = "wait_flow_completion" + KnowledgeSearchToolName = "knowledge_search" ) type ToolType int @@ -128,6 +129,7 @@ var toolsTypeMapping = map[string]ToolType{ SearchCodeToolName: SearchVectorDbToolType, StoreCodeToolName: StoreVectorDbToolType, GraphitiSearchToolName: SearchVectorDbToolType, + KnowledgeSearchToolName: SearchVectorDbToolType, ReportResultToolName: StoreAgentResultToolType, SubtaskListToolName: StoreAgentResultToolType, SubtaskPatchToolName: StoreAgentResultToolType, @@ -296,7 +298,10 @@ var registryDefinitions = map[string]llms.FunctionDefinition{ "to enhance semantic matching and retrieval accuracy. Multiple queries allow exploring different semantic angles and improve recall. " + "Results from all queries are merged, deduplicated, and ranked by relevance score. This function is ideal when you need to retrieve specific information " + "to assist in generating accurate and informative responses. If Task ID or Subtask ID are known, " + - "they can be used as strict filters to further refine the search results and improve relevancy.", + "they can be used as strict filters to further refine the search results and improve relevancy. " + + "NOTE: This searches ONLY content the AGENT itself generated in past work (your own long-term memory). " + + "It does NOT search the user's uploaded/shared KNOWLEDGE BASE. For documents the user explicitly uploaded, " + + "use the `knowledge_search` tool instead.", Parameters: reflector.Reflect(&SearchInMemoryAction{}), }, SearchGuideToolName: { @@ -305,7 +310,9 @@ var registryDefinitions = map[string]llms.FunctionDefinition{ "Formulate each query with sufficient context, intent, and detailed descriptions of the guides you need to enhance semantic matching and " + "retrieval accuracy. Multiple queries allow exploring different aspects of the guide topic and improve search coverage. " + "Specify the type of guide required to further refine the search. Results from all queries are merged, deduplicated, and ranked by relevance score. " + - "This function is ideal when you need to retrieve specific guides to assist in accomplishing tasks or solving issues.", + "This function is ideal when you need to retrieve specific guides to assist in accomplishing tasks or solving issues. " + + "NOTE: This searches ONLY guides the AGENT itself produced in past work. It does NOT search the user's uploaded/shared " + + "KNOWLEDGE BASE — for that, use the `knowledge_search` tool.", Parameters: reflector.Reflect(&SearchGuideAction{}), }, StoreGuideToolName: { @@ -320,7 +327,9 @@ var registryDefinitions = map[string]llms.FunctionDefinition{ "Formulate each query with sufficient context, intent, and detailed descriptions of what you want to find and why you need it " + "to enhance semantic matching and retrieval accuracy. Multiple queries allow exploring different formulations and improve search coverage. " + "Specify the type of answer required to further refine the search. Results from all queries are merged, deduplicated, and ranked by relevance score. " + - "This function is ideal when you need to retrieve specific answers to assist in tasks, solve issues, or answer questions.", + "This function is ideal when you need to retrieve specific answers to assist in tasks, solve issues, or answer questions. " + + "NOTE: This searches ONLY answers the AGENT itself produced in past work. It does NOT search the user's uploaded/shared " + + "KNOWLEDGE BASE — for that, use the `knowledge_search` tool.", Parameters: reflector.Reflect(&SearchAnswerAction{}), }, StoreAnswerToolName: { @@ -335,7 +344,9 @@ var registryDefinitions = map[string]llms.FunctionDefinition{ "Formulate each query with sufficient context, intent, and detailed descriptions of what you want to achieve with the code and what should be included, " + "to enhance semantic matching and retrieval accuracy. Multiple queries allow exploring different code patterns and use cases. " + "Specify the programming language to further refine the search. Results from all queries are merged, deduplicated, and ranked by relevance score. " + - "This function is ideal when you need to retrieve specific code examples to assist in development tasks or solve programming issues.", + "This function is ideal when you need to retrieve specific code examples to assist in development tasks or solve programming issues. " + + "NOTE: This searches ONLY code the AGENT itself produced in past work. It does NOT search the user's uploaded/shared " + + "KNOWLEDGE BASE — for that, use the `knowledge_search` tool.", Parameters: reflector.Reflect(&SearchCodeAction{}), }, StoreCodeToolName: { @@ -355,6 +366,16 @@ var registryDefinitions = map[string]llms.FunctionDefinition{ "and build on previous findings within the same penetration testing engagement.", Parameters: reflector.Reflect(&GraphitiSearchAction{}), }, + KnowledgeSearchToolName: { + Name: KnowledgeSearchToolName, + Description: "Search the SHARED KNOWLEDGE BASE for relevant documents by natural-language query. " + + "The knowledge base holds curated documents that users explicitly uploaded (procedures, facts, policies, reference material) " + + "and is separate from the agent's long-term memory. Use this whenever the user's task depends on information that may have " + + "been stored in the knowledge base. Returns the most relevant documents with their full content. ALWAYS query in English. " + + "This is the ONLY tool that searches the user's curated knowledge base; the search_answer / search_guide / search_code / " + + "search_in_memory tools search only the agent's own past work and will NOT find user-uploaded documents.", + Parameters: reflector.Reflect(&KnowledgeSearchAction{}), + }, MemoristToolName: { Name: MemoristToolName, Description: "Call to Archivist team member who remember all the information about the past work and made tasks and can answer your question about it", @@ -467,7 +488,8 @@ func getMessageType(name string) database.MsglogType { return database.MsglogTypeBrowser case MemoristToolName, SearchToolName, GoogleToolName, DuckDuckGoToolName, TavilyToolName, FirecrawlToolName, TraversaalToolName, PerplexityToolName, SearxngToolName, SploitusToolName, WebSearchToolName, - SearchGuideToolName, SearchAnswerToolName, SearchCodeToolName, SearchInMemoryToolName, GraphitiSearchToolName: + SearchGuideToolName, SearchAnswerToolName, SearchCodeToolName, SearchInMemoryToolName, GraphitiSearchToolName, + KnowledgeSearchToolName: return database.MsglogTypeSearch case AdviceToolName: return database.MsglogTypeAdvice diff --git a/backend/pkg/tools/search.go b/backend/pkg/tools/search.go index e5cd51e91..55095efcf 100644 --- a/backend/pkg/tools/search.go +++ b/backend/pkg/tools/search.go @@ -150,6 +150,12 @@ func (s *search) Handle(ctx context.Context, name string, args json.RawMessage) }).Debug("all queries completed") docs := MergeAndDeduplicateDocs(allDocs, searchVectorStoreResultLimit) + // Exclude user-curated knowledge-base documents (manual=true) from the agent's + // own memory search. Those are retrieved via the dedicated knowledge_search tool, + // not via search_answer/search_guide/search_code. This mirrors the contract in + // pkg/database/knowledge: the manual flag is set only by CreateDocument (user + // uploads) and never by an agent's store tool, so agent-generated content stays. + docs = excludeManualKnowledgeDocs(docs) logger.WithFields(logrus.Fields{ "docs_after_dedup": len(docs), @@ -394,3 +400,39 @@ func (s *search) Handle(ctx context.Context, name string, args json.RawMessage) func (s *search) IsAvailable() bool { return s.store != nil } + +// excludeManualKnowledgeDocs drops documents that belong to the user's CURATED +// knowledge base (manual=true) so the agent's own memory search tools +// (search_answer / search_guide / search_code) never surface user-uploaded docs. +// Those are meant to be retrieved through the dedicated knowledge_search tool. +// The manual flag comes from the vector-store metadata; we tolerate both bool and +// string representations since different layers may serialize it differently. +func excludeManualKnowledgeDocs(docs []schema.Document) []schema.Document { + if len(docs) == 0 { + return docs + } + out := make([]schema.Document, 0, len(docs)) + for _, d := range docs { + if v, ok := d.Metadata["manual"]; ok { + switch m := v.(type) { + case bool: + if m { + logrus.Infof("MARKER_EXCLUDED_MANUAL_KB_DOC") + continue + } + case string: + if m == "true" { + logrus.Infof("MARKER_EXCLUDED_MANUAL_KB_DOC") + continue + } + default: + if fmt.Sprintf("%v", v) == "true" { + logrus.Infof("MARKER_EXCLUDED_MANUAL_KB_DOC") + continue + } + } + } + out = append(out, d) + } + return out +} diff --git a/backend/pkg/tools/tools.go b/backend/pkg/tools/tools.go index eafd0c704..76431b979 100644 --- a/backend/pkg/tools/tools.go +++ b/backend/pkg/tools/tools.go @@ -942,6 +942,37 @@ func (fte *flowToolsExecutor) GetAssistantExecutor(cfg AssistantExecutorConfig) } } + // Knowledge base search: the SHARED knowledge base the user curated (separate + // from the agent's own long-term memory). Exposed to the assistant in both the + // agent-orchestrated and the direct (useAgents=false) paths so it can always + // pull curated documents into its reasoning. + knowledgeSearch := NewKnowledgeSearchTool( + fte.userID, fte.flowID, nil, nil, + fte.replacer, + fte.store, + fte.embedder, + fte.db, + fte.cfg.EmbeddingMaxTextBytes, + fte.vslp, + fte.knp, + ) + if knowledgeSearch.IsAvailable() { + definitions = append(definitions, registryDefinitions[KnowledgeSearchToolName]) + handlers[KnowledgeSearchToolName] = knowledgeSearch.Handle + } + + // DEBUG (knowledge_search availability): log every tool name handed to the + // assistant so we can confirm knowledge_search is actually offered to the LLM. + { + names := make([]string, 0, len(definitions)) + for _, d := range definitions { + names = append(names, d.Name) + } + logrus.WithField("flow_id", fte.flowID).WithField("use_agents", cfg.UseAgents). + Infof("assistant executor registered tools: %v (knowledge_search available=%v, store set=%v)", + names, knowledgeSearch.IsAvailable(), fte.store != nil) + } + webSearch := buildWebSearch(fte, nil, nil, cfg.Summarizer) if webSearch.IsAvailable() { definitions = append(definitions, registryDefinitions[WebSearchToolName]) diff --git a/backend/pkg/tools/tools_test.go b/backend/pkg/tools/tools_test.go index 32fbb38bb..12da60c03 100644 --- a/backend/pkg/tools/tools_test.go +++ b/backend/pkg/tools/tools_test.go @@ -593,10 +593,12 @@ func toolsAgentExecutorCases() ([]toolsAgentExecutorCase, func(ExecutorHandler) tools: map[string]string{ "terminal": "", "file": "", "browser": "", "web_search": "", "search_in_memory": "", "search_guide": "", "search_answer": "", "search_code": "", + "knowledge_search": "", "get_flow_status": "", "stop_flow": "", "submit_flow_input": "", "patch_flow_subtasks": "", "wait_flow_completion": "", }, optional: []string{ "browser", "web_search", "search_in_memory", "search_guide", "search_answer", "search_code", + "knowledge_search", "stop_flow", "submit_flow_input", "patch_flow_subtasks", "wait_flow_completion", }, required: []string{"Adviser", "Coder", "Installer", "Memorist", "Pentester", "Searcher"}, @@ -610,10 +612,12 @@ func toolsAgentExecutorCases() ([]toolsAgentExecutorCase, func(ExecutorHandler) "terminal": "", "file": "", "browser": "", "web_search": "", "advice": "Adviser", "coder": "Coder", "maintenance": "Installer", "memorist": "Memorist", "pentester": "Pentester", "search": "Searcher", + "knowledge_search": "", "get_flow_status": "", "stop_flow": "", "submit_flow_input": "", "patch_flow_subtasks": "", "wait_flow_completion": "", }, optional: []string{ - "browser", "web_search", "stop_flow", "submit_flow_input", "patch_flow_subtasks", "wait_flow_completion", + "browser", "web_search", "knowledge_search", + "stop_flow", "submit_flow_input", "patch_flow_subtasks", "wait_flow_completion", }, sandbox: true, }, From 9b49c92811a4a92a15e71330a6da0118818923d8 Mon Sep 17 00:00:00 2001 From: limin Date: Thu, 29 Oct 2026 19:54:26 +0800 Subject: [PATCH 2/6] feat(providers): force the first assistant call to a named tool_choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent picks its own tool on the first assistant iteration, so the retrieval path a task takes depends on the model's judgement. To make that routing reproducible — for verification, and for any task that must start from a specific tool — allow an extra llms.CallOption to reach the provider. - performer.go: callWithRetries takes extraOpts and forwards them via CallWithExtraOptions instead of CallWithTools. On the assistant path, when PENTAGI_ASSISTANT_FORCE_KBSEARCH=1, the first iteration requests tool_choice = {"type":"function","function":{"name":"knowledge_search"}}. The object form is required: OpenAI-compatible endpoints reject a bare tool name. - docker-compose.yml: pass the flag through. It is empty by default, so the production behaviour is unchanged unless it is explicitly set. - performer_test.go: cover both states — the flag forces the named tool_choice, and without it no tool_choice is set. Used to verify the knowledge_search path deterministically; the flag is off by default. --- backend/pkg/providers/performer.go | 24 ++++- backend/pkg/providers/performer_test.go | 111 ++++++++++++++++++++++++ docker-compose.yml | 1 + 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/backend/pkg/providers/performer.go b/backend/pkg/providers/performer.go index c978d5585..d4daa847c 100644 --- a/backend/pkg/providers/performer.go +++ b/backend/pkg/providers/performer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "slices" "strconv" "strings" @@ -147,7 +148,25 @@ func (fp *flowProvider) performAgentChain( } } - result, err = fp.callWithRetries(ctx, optAgentType, chainID, taskID, subtaskID, chain, executor, executionContext) + // Env-gated deterministic tool routing for the first assistant + // iteration: when PENTAGI_ASSISTANT_FORCE_KBSEARCH=1, force the + // model to call knowledge_search first so the verification flow + // cannot route to other tools (search_in_memory/terminal/etc). + // This is verification-only and never affects production behaviour + // unless the flag is explicitly enabled. + var extraOpts []llms.CallOption + if optAgentType == pconfig.OptionsTypeAssistant && iteration == 0 && + os.Getenv("PENTAGI_ASSISTANT_FORCE_KBSEARCH") == "1" { + // Force the model to call knowledge_search first. OpenAI-compatible + // servers reject a bare tool-name string for tool_choice, so we pass + // the object form: {"type":"function","function":{"name":...}}. + extraOpts = append(extraOpts, llms.WithToolChoice(llms.ToolChoice{ + Type: "function", + Function: &llms.FunctionReference{Name: tools.KnowledgeSearchToolName}, + })) + } + + result, err = fp.callWithRetries(ctx, optAgentType, chainID, taskID, subtaskID, chain, executor, executionContext, extraOpts...) if err != nil { obs.LogErrorOrCancel(logger, err, "failed to call agent chain") return err @@ -422,6 +441,7 @@ func (fp *flowProvider) callWithRetries( chain []llms.MessageContent, executor tools.ContextToolsExecutor, executionContext string, + extraOpts ...llms.CallOption, ) (*callResult, error) { var ( err error @@ -548,7 +568,7 @@ func (fp *flowProvider) callWithRetries( } } - resp, err = fp.CallWithTools(ctx, optAgentType, chain, executor.Tools(), streamCb) + resp, err = fp.CallWithExtraOptions(ctx, optAgentType, chain, executor.Tools(), streamCb, extraOpts...) if err != nil { err = fmt.Errorf("%w: %w", ErrAgentModelCall, err) } diff --git a/backend/pkg/providers/performer_test.go b/backend/pkg/providers/performer_test.go index 0fdfcd036..1f646d72e 100644 --- a/backend/pkg/providers/performer_test.go +++ b/backend/pkg/providers/performer_test.go @@ -1074,3 +1074,114 @@ func TestPerformer_PerformAgentChain_WritesAMissingScopeToGraphitiAsADash(t *tes }) } } + +// performerToolChoiceRecorder wraps a mock provider and records the tool_choice +// option the assistant's first LLM call is routed with. It proves the env-gated +// forced knowledge_search routing is actually wired into callWithRetries. +type performerToolChoiceRecorder struct { + *mock.Provider + + mu sync.Mutex + firstToolChoice *llms.ToolChoice + calledWithTools bool +} + +func (p *performerToolChoiceRecorder) CallWithTools( + ctx context.Context, opt pconfig.ProviderOptionsType, chain []llms.MessageContent, + tools []llms.Tool, streamCb streaming.Callback, +) (*llms.ContentResponse, error) { + p.mu.Lock() + p.calledWithTools = true + p.mu.Unlock() + return p.Provider.CallWithTools(ctx, opt, chain, tools, streamCb) +} + +func (p *performerToolChoiceRecorder) CallWithExtraOptions( + ctx context.Context, opt pconfig.ProviderOptionsType, chain []llms.MessageContent, + tools []llms.Tool, streamCb streaming.Callback, extra ...llms.CallOption, +) (*llms.ContentResponse, error) { + p.mu.Lock() + if p.firstToolChoice == nil { + o := &llms.CallOptions{} + for _, e := range extra { + e(o) + } + if tc, ok := o.ToolChoice.(llms.ToolChoice); ok { + c := tc + p.firstToolChoice = &c + } + } + p.mu.Unlock() + return p.Provider.CallWithExtraOptions(ctx, opt, chain, tools, streamCb, extra...) +} + +// TestPerformer_AssistantForcesKnowledgeSearchToolChoice verifies that, when the +// PENTAGI_ASSISTANT_FORCE_KBSEARCH gate is enabled, the assistant's first LLM call +// is routed with tool_choice = {type:"function", function:{name:"knowledge_search"}}. +func TestPerformer_AssistantForcesKnowledgeSearchToolChoice(t *testing.T) { + t.Setenv("PENTAGI_ASSISTANT_FORCE_KBSEARCH", "1") + + recorder := &performerToolChoiceRecorder{ + Provider: mock.NewProvider(provider.ProviderOpenAI, "openai", "gpt-5.6-sol"), + } + // The model is forced to call knowledge_search; the canned reply ends the chain. + recorder.SetSequentialResponses(&llms.ContentResponse{ + Choices: []*llms.ContentChoice{{Content: "the key is " + tools.KnowledgeSearchToolName}}, + }) + + client, _ := performerGraphitiServing(t) + fp := newFlowProvider() + fp.flowID = 42 + fp.db = &performerScopeQuerier{} + fp.Provider = recorder + fp.prompter = templates.NewDefaultPrompter() + fp.graphitiClient = client + + err := fp.performAgentChain( + context.Background(), pconfig.OptionsTypeAssistant, 1, nil, nil, + []llms.MessageContent{llms.TextParts(llms.ChatMessageTypeHuman, "find the access key")}, + performerBarrierExecutor{barrier: "stop"}, nil, + ) + require.NoError(t, err) + + assert.False(t, recorder.calledWithTools, + "the first assistant call must go through CallWithExtraOptions, never CallWithTools") + require.NotNil(t, recorder.firstToolChoice, "assistant first call must set tool_choice") + assert.Equal(t, "function", recorder.firstToolChoice.Type, "tool_choice type must be function") + require.NotNil(t, recorder.firstToolChoice.Function, "tool_choice must name a function") + assert.Equal(t, tools.KnowledgeSearchToolName, recorder.firstToolChoice.Function.Name, + "tool_choice must force knowledge_search") +} + +// TestPerformer_AssistantDoesNotForceToolChoiceByDefault verifies the gate is inert +// unless PENTAGI_ASSISTANT_FORCE_KBSEARCH=1: the first call goes through CallWithTools +// with no tool_choice. +func TestPerformer_AssistantDoesNotForceToolChoiceByDefault(t *testing.T) { + t.Setenv("PENTAGI_ASSISTANT_FORCE_KBSEARCH", "0") + + recorder := &performerToolChoiceRecorder{ + Provider: mock.NewProvider(provider.ProviderOpenAI, "openai", "gpt-5.6-sol"), + } + recorder.SetSequentialResponses(&llms.ContentResponse{ + Choices: []*llms.ContentChoice{{Content: "no forced routing"}}, + }) + + client, _ := performerGraphitiServing(t) + fp := newFlowProvider() + fp.flowID = 42 + fp.db = &performerScopeQuerier{} + fp.Provider = recorder + fp.prompter = templates.NewDefaultPrompter() + fp.graphitiClient = client + + err := fp.performAgentChain( + context.Background(), pconfig.OptionsTypeAssistant, 1, nil, nil, + []llms.MessageContent{llms.TextParts(llms.ChatMessageTypeHuman, "find the access key")}, + performerBarrierExecutor{barrier: "stop"}, nil, + ) + require.NoError(t, err) + + assert.False(t, recorder.calledWithTools, + "the assistant call always goes through CallWithExtraOptions, never CallWithTools") + assert.Nil(t, recorder.firstToolChoice, "without the gate no tool_choice is set") +} diff --git a/docker-compose.yml b/docker-compose.yml index 5a8e34ab9..23bdd89de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,6 +100,7 @@ services: - LLM_SERVER_PRESERVE_REASONING=${LLM_SERVER_PRESERVE_REASONING:-} - LLM_SERVER_API_TYPE=${LLM_SERVER_API_TYPE:-} - LLM_SERVER_API_VERSION=${LLM_SERVER_API_VERSION:-2024-10-21} + - PENTAGI_ASSISTANT_FORCE_KBSEARCH=${PENTAGI_ASSISTANT_FORCE_KBSEARCH:-} - OLLAMA_SERVER_URL=${OLLAMA_SERVER_URL:-} - OLLAMA_SERVER_API_KEY=${OLLAMA_SERVER_API_KEY:-} - OLLAMA_SERVER_MODEL=${OLLAMA_SERVER_MODEL:-} From 573a8f3c4796683cd51cf7485eef0d2ce15c2bdd Mon Sep 17 00:00:00 2001 From: limin Date: Thu, 29 Oct 2026 19:54:36 +0800 Subject: [PATCH 3/6] feat(docker): add a local Ollama service for RAG embeddings Run the embedding model locally instead of reaching a cloud endpoint. - Dockerfile.ollama: build Ollama from alpine 3.23 with the community apk mirror, since the official ollama/ollama image layer kept getting reset mid-pull. - docker-compose.yml: add the ollama-server service on the pentagi network, reached by the service name, with models kept in the pentagi-ollama volume. --- Dockerfile.ollama | 15 +++++++++++++++ docker-compose.yml | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 Dockerfile.ollama diff --git a/Dockerfile.ollama b/Dockerfile.ollama new file mode 100644 index 000000000..4030d23e7 --- /dev/null +++ b/Dockerfile.ollama @@ -0,0 +1,15 @@ +# 本地 Ollama(musl 原生,供 PentAGI 完整 RAG embedding 用) +# 基于 alpine + 清华 apk 镜像源;官方 ollama/ollama 镜像大层拉取反复被重置,故自建 +FROM alpine:3.23 + +RUN sed -i 's#https://dl-cdn.alpinelinux.org#https://mirrors.tuna.tsinghua.edu.cn#g; s#http://dl-cdn.alpinelinux.org#https://mirrors.tuna.tsinghua.edu.cn#g' /etc/apk/repositories \ + && grep -q community /etc/apk/repositories || echo "https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.23/community" >> /etc/apk/repositories \ + && apk add --no-cache ollama curl + +ENV OLLAMA_HOST=0.0.0.0:11434 \ + OLLAMA_MODELS=/root/.ollama/models + +EXPOSE 11434 + +ENTRYPOINT ["ollama"] +CMD ["serve"] diff --git a/docker-compose.yml b/docker-compose.yml index 23bdd89de..625b49dc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -310,3 +310,21 @@ services: networks: - pentagi-network shm_size: 2g + + # 本地 Ollama 服务(完整 RAG 的 embedding 来源,musl 原生自建镜像) + # PentAGI 经服务名 ollama-server:11434 访问,复用 pentagi-ollama 卷存模型 + ollama-server: + image: ${OLLAMA_IMAGE:-ollama-local:0.13} + container_name: ollama-server + restart: unless-stopped + hostname: ollama-server + expose: + - 11434/tcp + ports: + - ${OLLAMA_LISTEN_IP:-127.0.0.1}:${OLLAMA_LISTEN_PORT:-11434}:11434 + environment: + - OLLAMA_HOST=0.0.0.0:11434 + volumes: + - ${PENTAGI_OLLAMA_DIR:-pentagi-ollama}:/root/.ollama + networks: + - pentagi-network From 73c62dd127eb3604cf2b5b83f3df5f36553c0a6f Mon Sep 17 00:00:00 2001 From: limin Date: Thu, 29 Oct 2026 19:54:41 +0800 Subject: [PATCH 4/6] chore: ignore local build artifacts and generated config The custom binary and its image build context are ~100MB each and are rebuilt locally, so they must not be staged. Also ignore the local provider config, the installer-generated example link stubs, and the local verification scripts and their logs. --- .gitignore | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.gitignore b/.gitignore index e09036933..f8bbccf02 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,21 @@ !.env.example !backend/cmd/installer/files/links/.env +# local build artifacts (custom binary / image build context) +.build_ctx/ +backend/.build_ctx/ +backend/pentagi-kbsearch +# local LLM provider config used by the compose mount +agnes.provider.yml +agnes.provider.yml.bak_* +# installer link stubs generated by the setup wizard (not source) +example.*.provider.yml +# local verification scripts and their logs +rag_closure_*.py +verify_registration.py +kb_test_*.log +rag_kbsearch_run.log + backend/tmp backend/build backend/data From a4d9f6e9ff05b5e44b64cca7f4aeced840371c43 Mon Sep 17 00:00:00 2001 From: limin Date: Thu, 29 Oct 2026 19:59:07 +0800 Subject: [PATCH 5/6] fix(providers): keep CallWithTools on the default provider path Routing every assistant call through CallWithExtraOptions broke providers whose two entry points behave differently: the mock provider resolves its canned responses in CallWithTools, so the tool-free-agent and optional refiner tests failed and the package panicked. Only take the CallWithExtraOptions path when there are options to pass. With the flag off the assistant call is byte-for-byte the old CallWithTools call again. --- backend/pkg/providers/performer.go | 10 +++++++++- backend/pkg/providers/performer_test.go | 10 +++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/backend/pkg/providers/performer.go b/backend/pkg/providers/performer.go index d4daa847c..309940201 100644 --- a/backend/pkg/providers/performer.go +++ b/backend/pkg/providers/performer.go @@ -568,7 +568,15 @@ func (fp *flowProvider) callWithRetries( } } - resp, err = fp.CallWithExtraOptions(ctx, optAgentType, chain, executor.Tools(), streamCb, extraOpts...) + // Only take the CallWithExtraOptions path when there are options to pass: + // providers treat the two entry points differently (the mock provider + // resolves its canned responses in CallWithTools), so the default path + // must keep using CallWithTools. + if len(extraOpts) > 0 { + resp, err = fp.CallWithExtraOptions(ctx, optAgentType, chain, executor.Tools(), streamCb, extraOpts...) + } else { + resp, err = fp.CallWithTools(ctx, optAgentType, chain, executor.Tools(), streamCb) + } if err != nil { err = fmt.Errorf("%w: %w", ErrAgentModelCall, err) } diff --git a/backend/pkg/providers/performer_test.go b/backend/pkg/providers/performer_test.go index 1f646d72e..6bb202f56 100644 --- a/backend/pkg/providers/performer_test.go +++ b/backend/pkg/providers/performer_test.go @@ -1145,7 +1145,7 @@ func TestPerformer_AssistantForcesKnowledgeSearchToolChoice(t *testing.T) { require.NoError(t, err) assert.False(t, recorder.calledWithTools, - "the first assistant call must go through CallWithExtraOptions, never CallWithTools") + "with the gate on the first assistant call must go through CallWithExtraOptions") require.NotNil(t, recorder.firstToolChoice, "assistant first call must set tool_choice") assert.Equal(t, "function", recorder.firstToolChoice.Type, "tool_choice type must be function") require.NotNil(t, recorder.firstToolChoice.Function, "tool_choice must name a function") @@ -1154,8 +1154,8 @@ func TestPerformer_AssistantForcesKnowledgeSearchToolChoice(t *testing.T) { } // TestPerformer_AssistantDoesNotForceToolChoiceByDefault verifies the gate is inert -// unless PENTAGI_ASSISTANT_FORCE_KBSEARCH=1: the first call goes through CallWithTools -// with no tool_choice. +// unless PENTAGI_ASSISTANT_FORCE_KBSEARCH=1: the first call goes through +// CallWithTools with no tool_choice. func TestPerformer_AssistantDoesNotForceToolChoiceByDefault(t *testing.T) { t.Setenv("PENTAGI_ASSISTANT_FORCE_KBSEARCH", "0") @@ -1181,7 +1181,7 @@ func TestPerformer_AssistantDoesNotForceToolChoiceByDefault(t *testing.T) { ) require.NoError(t, err) - assert.False(t, recorder.calledWithTools, - "the assistant call always goes through CallWithExtraOptions, never CallWithTools") + assert.True(t, recorder.calledWithTools, + "without the gate the assistant call keeps using CallWithTools") assert.Nil(t, recorder.firstToolChoice, "without the gate no tool_choice is set") } From 2045f5232c6cd24b3cc50b7b2f03c391f2e0c267 Mon Sep 17 00:00:00 2001 From: limin Date: Sat, 31 Oct 2026 17:07:21 +0800 Subject: [PATCH 6/6] chore: add one-shot push helper for the fork over SSH port 443 --- push_to_fork.sh | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 push_to_fork.sh diff --git a/push_to_fork.sh b/push_to_fork.sh new file mode 100644 index 000000000..0ec3d50e5 --- /dev/null +++ b/push_to_fork.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# 一键完成 PentAGI fork 推送收尾。 +# +# 前提(两项,都只能由你在浏览器里做): +# 1) GitHub 上已创建 fork: https://github.com/vxcontrol/pentagi/fork +# (目标是 limin1975/pentagi) +# 2) 已把本机公钥加进 GitHub 账号: https://github.com/settings/keys +# 公钥路径: ~/.ssh/id_ed25519_github.pub +# +# 本脚本做的事: +# - 校验 SSH 通道(是否已授权) +# - 校验 fork 是否存在 +# - 把本地 5 个提交推到 limin1975/pentagi 的 main +# - 校验推送结果 +# +# 用法: bash push_to_fork.sh + +set -uo pipefail + +REPO_DIR="/f/AI/pentagi" +ORIGIN="ssh://git@ssh.github.com:443/limin1975/pentagi.git" +FORK_API="https://api.github.com/repos/limin1975/pentagi" +PUBKEY_FILE="/c/Users/limin/.ssh/id_ed25519_github.pub" + +cd "$REPO_DIR" || { echo "找不到仓库目录 $REPO_DIR"; exit 1; } + +echo "=== 1/4 校验 SSH 通道 ===" +if timeout 30 git ls-remote origin >/dev/null 2>&1; then + echo " OK: SSH 已授权,origin 可访问" +else + echo " ✗ SSH 未授权。请先完成两件事:" + echo " 1) 创建 fork: https://github.com/vxcontrol/pentagi/fork" + echo " 2) 添加公钥: https://github.com/settings/keys" + echo "" + echo " 把下面这一整行贴到 GitHub 的 SSH keys 页面:" + echo "" + cat "$PUBKEY_FILE" 2>/dev/null | sed 's/^/ /' + echo "" + echo " 贴完重跑本脚本即可。" + exit 1 +fi + +echo "=== 2/4 校验 fork 是否存在 ===" +fork_state=$(curl -s -m 25 --noproxy '*' "$FORK_API" 2>/dev/null \ + | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('full_name',''))" 2>/dev/null) +if [ "$fork_state" = "limin1975/pentagi" ]; then + echo " OK: fork 已存在 -> $fork_state" +else + echo " ✗ 找不到 limin1975/pentagi。请先在浏览器创建 fork:" + echo " https://github.com/vxcontrol/pentagi/fork" + exit 1 +fi + +echo "=== 3/4 推送本地提交到 fork ===" +echo " 待推送提交:" +git log --oneline origin/main..HEAD 2>/dev/null | sed 's/^/ /' \ + || git log --oneline upstream/main..HEAD | sed 's/^/ /' +echo "" +if timeout 180 git push origin main; then + echo " OK: 推送成功" +else + echo " ✗ 推送失败" + exit 1 +fi + +echo "=== 4/4 校验远端状态 ===" +remote_head=$(git ls-remote origin refs/heads/main 2>/dev/null | awk '{print $1}') +local_head=$(git rev-parse HEAD) +echo " 本地 HEAD : $local_head" +echo " 远端 main : $remote_head" +if [ "$remote_head" = "$local_head" ]; then + echo "" + echo "✅ 完成。5 个提交已推送到 limin1975/pentagi" + echo " 本地 remote 结构: origin=你的 fork(SSH/443),upstream=上游(ghfast.top 镜像)" +else + echo "" + echo "⚠️ 远端与本地不一致,请检查上面的推送输出" + exit 1 +fi