agent build
This commit is contained in:
162
go-api/internal/tools/knowledge.go
Normal file
162
go-api/internal/tools/knowledge.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/knowledge"
|
||||
)
|
||||
|
||||
// knowledge_search: retrieval the model can drive.
|
||||
//
|
||||
// The loop already retrieves once, for the caller's opening question, and puts
|
||||
// the result in a <context> block. That covers the common case and covers it
|
||||
// cheaply — but it is one shot at one phrasing, and the phrasing is the user's.
|
||||
// A question like "am I allowed to leave early on Fridays?" retrieves a
|
||||
// paragraph about early departure and misses the one about shift-swap approvals
|
||||
// that actually answers it.
|
||||
//
|
||||
// So the model gets a second bite: it may search again, in its own words, once
|
||||
// it knows what it is looking for. That is worth a tool.
|
||||
//
|
||||
// What it emphatically does NOT get is a widening of scope. The sources come
|
||||
// from the agent's spec by way of ctx.KnowledgeSources, exactly as the
|
||||
// principal does; there is no `source` argument, because an argument is
|
||||
// something a model can choose and the set of corpora an agent may read is not
|
||||
// the model's to choose. All the model controls is the words.
|
||||
|
||||
type knowledgeSearchInput struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// KnowledgeSearch builds the search tool.
|
||||
//
|
||||
// One registered tool, not one per agent: the per-agent part is the source
|
||||
// list, and that travels on the Context beside the principal rather than being
|
||||
// closed over. Both are set by the loop from records the conversation cannot
|
||||
// touch, so two agents sharing this tool still cannot read each other's
|
||||
// corpora — and the registry stays a flat set of names, which is what §3's
|
||||
// publish-time validation resolves against.
|
||||
func KnowledgeSearch(r *knowledge.Retriever) Tool {
|
||||
return Tool{
|
||||
Name: "knowledge_search",
|
||||
Description: "Search the documents this agent has access to and get back passages " +
|
||||
"with source ids. Use it when the answer depends on what a written policy, " +
|
||||
"handbook or guide actually says — and search again in your own words if the " +
|
||||
"first passages are close but not quite right. Cite the source id of anything " +
|
||||
"you rely on.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "What to look for. Write it as the words you would expect to " +
|
||||
"find in the document, not as a question to a person.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 20,
|
||||
"description": "How many passages to return. Defaults to 8.",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
if r == nil {
|
||||
return Failf(CodeUnavailable, "there are no documents to search")
|
||||
}
|
||||
if len(tc.KnowledgeSources) == 0 {
|
||||
// This agent's spec named no knowledge. Refused rather than
|
||||
// widened: an empty source list is not permission to read
|
||||
// everything, and the tool should not have been offered.
|
||||
return Failf(CodeUnavailable, "this agent has no documents to search")
|
||||
}
|
||||
|
||||
var in knowledgeSearchInput
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
if strings.TrimSpace(in.Query) == "" {
|
||||
return Failf(CodeInvalidInput, "a search needs something to search for")
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = knowledge.DefaultK
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// The principal is the caller's. Not the agent's, not a service
|
||||
// account, and not anything the model supplied — I1 lives in this
|
||||
// one line as much as anywhere in the package.
|
||||
res, err := r.Retrieve(ctx, knowledge.Query{
|
||||
Text: in.Query,
|
||||
Principal: tc.Principal,
|
||||
Sources: tc.KnowledgeSources,
|
||||
K: limit,
|
||||
})
|
||||
if err != nil {
|
||||
var kErr *knowledge.Error
|
||||
if ok := asKnowledge(err, &kErr); ok && kErr.Code == knowledge.ErrNoPrincipal {
|
||||
// A caller retrieval will not serve is refused the same way
|
||||
// every other resource refuses one. Saying "you have no
|
||||
// tenant" would be a more useful error and a worse one.
|
||||
return Denied()
|
||||
}
|
||||
return Failf(CodeFailed, "the documents could not be searched")
|
||||
}
|
||||
|
||||
passages := make([]map[string]any, 0, len(res.Chunks))
|
||||
for _, c := range res.Chunks {
|
||||
p := map[string]any{
|
||||
// The id first, because citing it is the point. §5: a claim
|
||||
// without a retrievable citation is inference, not grounded
|
||||
// fact, and the model can only tell them apart if every
|
||||
// passage arrived with an address.
|
||||
"sourceId": c.ChunkID,
|
||||
"title": c.Title,
|
||||
"text": c.Text,
|
||||
}
|
||||
if c.Heading != "" {
|
||||
p["section"] = c.Heading
|
||||
}
|
||||
if c.URI != "" {
|
||||
p["uri"] = c.URI
|
||||
}
|
||||
passages = append(passages, p)
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"query": in.Query,
|
||||
"passages": passages,
|
||||
"count": len(passages),
|
||||
}
|
||||
if len(passages) == 0 {
|
||||
data["note"] = "Nothing in these documents matched. This is a real answer, " +
|
||||
"not a failure to look — say so rather than answering from general knowledge."
|
||||
}
|
||||
if res.DenseSkipped != "" {
|
||||
// Handed to the model because it changes what an absence means.
|
||||
// "I found nothing" is a weaker claim when half the index was
|
||||
// not searched, and the model should be able to say which.
|
||||
data["degraded"] = res.DenseSkipped
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// asKnowledge is errors.As for the knowledge package's error type.
|
||||
func asKnowledge(err error, target **knowledge.Error) bool {
|
||||
if e, ok := err.(*knowledge.Error); ok {
|
||||
*target = e
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user