WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// toolErrorHint is appended to tool error messages to guide the LLM to retry with a different approach.
const toolErrorHint = "\n\n[Analyze the error above and try a different approach.]"
// ToolRegistry manages the registration and retrieval of tools
type ToolRegistry struct {
tools map[string]types.Tool
maxToolOutputSize int // maximum chars for tool output (0 = use DefaultMaxToolOutput)
}
// outputLimitProvider is implemented by tools that expose a caller-configurable
// output budget with their own hard safety cap. It prevents the registry's
// generic limit from undoing that explicit bounded choice.
type outputLimitProvider interface {
OutputLimitChars(args json.RawMessage) int
}
// NewToolRegistry creates a new tool registry
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{
tools: make(map[string]types.Tool),
}
}
// SetMaxToolOutputSize sets the maximum character length for tool output.
// Values <= 0 will use DefaultMaxToolOutput.
func (r *ToolRegistry) SetMaxToolOutputSize(maxChars int) {
r.maxToolOutputSize = maxChars
}
// getMaxToolOutput returns the effective max tool output size.
func (r *ToolRegistry) getMaxToolOutput() int {
if r.maxToolOutputSize > 0 {
return r.maxToolOutputSize
}
return DefaultMaxToolOutput
}
// RegisterTool adds a tool to the registry.
// If a tool with the same name is already registered, the existing one is kept
// (first-wins) to prevent tool execution hijacking via name collision (GHSA-67q9-58vj-32qx).
func (r *ToolRegistry) RegisterTool(tool types.Tool) {
name := tool.Name()
if _, exists := r.tools[name]; exists {
logger.Warnf(context.Background(),
"[ToolRegistry] Duplicate tool registration rejected: %s (first-wins policy)", name)
return
}
r.tools[name] = tool
}
// GetTool retrieves a tool by name
func (r *ToolRegistry) GetTool(name string) (types.Tool, error) {
tool, exists := r.tools[name]
if !exists {
return nil, fmt.Errorf("tool not found: %s", name)
}
return tool, nil
}
// ListTools returns all registered tool names sorted alphabetically.
// Sorting keeps the order stable across calls — Go map iteration is
// intentionally randomized.
func (r *ToolRegistry) ListTools() []string {
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
sort.Strings(names)
return names
}
// GetFunctionDefinitions returns function definitions for all registered tools.
// The slice is sorted by tool name so the serialized payload sent to the LLM
// is byte-identical across requests. Providers that key prompt caching on a
// byte-level prefix match (e.g. Qwen explicit caching) require this — map
// iteration order would otherwise reshuffle the tools block and break cache
// hits.
func (r *ToolRegistry) GetFunctionDefinitions() []types.FunctionDefinition {
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
sort.Strings(names)
definitions := make([]types.FunctionDefinition, 0, len(names))
for _, name := range names {
tool := r.tools[name]
definitions = append(definitions, types.FunctionDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: tool.Parameters(),
})
}
return definitions
}
// ExecuteTool executes a tool by name with the given arguments
func (r *ToolRegistry) ExecuteTool(
ctx context.Context,
name string,
args json.RawMessage,
) (*types.ToolResult, error) {
common.PipelineInfo(ctx, "AgentTool", "execute_start", map[string]interface{}{
"tool": name,
"args": args,
})
tool, err := r.GetTool(name)
if err != nil {
common.PipelineError(ctx, "AgentTool", "execute_failed", map[string]interface{}{
"tool": name,
"error": err.Error(),
})
return &types.ToolResult{
Success: false,
Error: err.Error() + toolErrorHint,
}, err
}
// Cast parameters to match expected schema types before execution.
// This handles common LLM quirks like returning "true" instead of true.
args = CastParams(args, tool.Parameters())
// Validate parameters against the tool's JSON Schema before execution.
// This catches invalid arguments early, avoiding a wasted tool execution + LLM round.
if validationErrs := ValidateParams(args, tool.Parameters()); len(validationErrs) > 0 {
errMsg := FormatValidationErrors(validationErrs)
if name == ToolWriteSandboxFile {
errMsg += writeSandboxMissingFieldHint
}
if name == ToolEditSandboxFile {
errMsg += editSandboxMissingFieldHint
}
errMsg += toolErrorHint
common.PipelineWarn(ctx, "AgentTool", "validation_failed", map[string]interface{}{
"tool": name,
"errors": errMsg,
})
return &types.ToolResult{
Success: false,
Error: errMsg,
}, nil
}
// Publish the ceiling so budget-aware tools can shape a batched result
// themselves; the truncation below stays as the fallback for the rest.
maxOutput := r.getMaxToolOutput()
if provider, ok := tool.(outputLimitProvider); ok {
if toolLimit := provider.OutputLimitChars(args); toolLimit > maxOutput {
maxOutput = toolLimit
}
}
result, execErr := tool.Execute(WithOutputBudget(ctx, maxOutput), args)
// Truncate large tool outputs to prevent context window poisoning. The
// limit is counted in runes to match TruncateToolOutput; comparing bytes
// here would leave CJK output effectively uncapped.
if result != nil && utf8.RuneCountInString(result.Output) > maxOutput {
result.Output = TruncateToolOutput(result.Output, maxOutput)
}
fields := map[string]interface{}{
"tool": name,
"args": args,
}
if result != nil {
fields["success"] = result.Success
if result.Error != "" {
fields["error"] = result.Error
}
}
if execErr != nil {
fields["error"] = execErr.Error()
common.PipelineError(ctx, "AgentTool", "execute_done", fields)
} else if result != nil && !result.Success {
// Append error hint to guide LLM to retry with a different approach
if result.Error != "" {
result.Error = result.Error + toolErrorHint
}
common.PipelineWarn(ctx, "AgentTool", "execute_done", fields)
} else {
common.PipelineInfo(ctx, "AgentTool", "execute_done", fields)
}
return result, execErr
}
// Cleanup cleans up all registered tools that implement the types.Cleanable interface.
// This is called at the end of agent sessions to release tool-specific resources.
func (r *ToolRegistry) Cleanup(ctx context.Context) {
for name, tool := range r.tools {
if cleanable, ok := tool.(types.Cleanable); ok {
logger.Infof(ctx, "[ToolRegistry] Cleaning up tool: %s", name)
cleanable.Cleanup(ctx)
}
}
}