Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions Dockerfile.ollama
Original file line number Diff line number Diff line change
@@ -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"]
15 changes: 12 additions & 3 deletions backend/pkg/database/knowledge/knowledge.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (

"pentagi/pkg/database"
"pentagi/pkg/graph/model"
"pentagi/pkg/graph/subscriptions"
"pentagi/pkg/providers/embeddings"

"github.com/google/uuid"
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions backend/pkg/providers/assistant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 30 additions & 2 deletions backend/pkg/providers/performer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -422,6 +441,7 @@ func (fp *flowProvider) callWithRetries(
chain []llms.MessageContent,
executor tools.ContextToolsExecutor,
executionContext string,
extraOpts ...llms.CallOption,
) (*callResult, error) {
var (
err error
Expand Down Expand Up @@ -548,7 +568,15 @@ func (fp *flowProvider) callWithRetries(
}
}

resp, err = fp.CallWithTools(ctx, optAgentType, chain, executor.Tools(), streamCb)
// 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)
}
Expand Down
111 changes: 111 additions & 0 deletions backend/pkg/providers/performer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"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")
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.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")
}
4 changes: 3 additions & 1 deletion backend/pkg/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions backend/pkg/templates/prompts/assistant.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</language_policy>

## 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
Expand Down Expand Up @@ -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}}
</available_tools>

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/pkg/templates/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ var PromptVariables = map[PromptType][]string{
"SearchGuideToolName",
"SearchAnswerToolName",
"SearchCodeToolName",
"KnowledgeSearchToolName",
"SummarizationToolName",
"SummarizedContentPrefix",
"UseAgents",
Expand Down
6 changes: 6 additions & 0 deletions backend/pkg/tools/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."`
Expand Down
Loading