Changeset 0.25.0 (#160)
This commit is contained in:
@@ -17,7 +17,7 @@ func init() {
|
||||
// calculator
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type CalculatorTool struct{}
|
||||
type CalculatorTool struct{ BaseTool }
|
||||
|
||||
func (t *CalculatorTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -22,6 +22,7 @@ func RegisterConversationSearch(stores store.Stores) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type conversationSearchTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func init() {
|
||||
// datetime
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type DateTimeTool struct{}
|
||||
type DateTimeTool struct{ BaseTool }
|
||||
|
||||
func (t *DateTimeTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -36,6 +36,7 @@ func RegisterFileRecall(stores store.Stores, objStore storage.ObjectStore) {
|
||||
const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images
|
||||
|
||||
type fileRecallTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// RegisterGitTools registers the 5 git-related workspace tools.
|
||||
// Only meaningful when a workspace has git_remote_url set — the completion
|
||||
// handler filters them out otherwise (via GitToolNames).
|
||||
// Each declares RequireWorkspace + DenyVisitor availability via workspaceToolBase.
|
||||
func RegisterGitTools(gitOps *workspace.GitOps) {
|
||||
Register(&gitStatusTool{gitOps: gitOps})
|
||||
Register(&gitDiffTool{gitOps: gitOps})
|
||||
@@ -19,14 +18,12 @@ func RegisterGitTools(gitOps *workspace.GitOps) {
|
||||
Register(&gitBranchTool{gitOps: gitOps})
|
||||
}
|
||||
|
||||
// GitToolNames returns the names of all git tools for filtering.
|
||||
func GitToolNames() []string {
|
||||
return []string{"git_status", "git_diff", "git_commit", "git_log", "git_branch"}
|
||||
}
|
||||
|
||||
// ── git_status ───────────────────────────────
|
||||
|
||||
type gitStatusTool struct{ gitOps *workspace.GitOps }
|
||||
type gitStatusTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitStatusTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -53,7 +50,10 @@ func (t *gitStatusTool) Execute(ctx context.Context, execCtx ExecutionContext, a
|
||||
|
||||
// ── git_diff ─────────────────────────────────
|
||||
|
||||
type gitDiffTool struct{ gitOps *workspace.GitOps }
|
||||
type gitDiffTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitDiffTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -92,7 +92,10 @@ func (t *gitDiffTool) Execute(ctx context.Context, execCtx ExecutionContext, arg
|
||||
|
||||
// ── git_commit ───────────────────────────────
|
||||
|
||||
type gitCommitTool struct{ gitOps *workspace.GitOps }
|
||||
type gitCommitTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitCommitTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -131,7 +134,10 @@ func (t *gitCommitTool) Execute(ctx context.Context, execCtx ExecutionContext, a
|
||||
|
||||
// ── git_log ──────────────────────────────────
|
||||
|
||||
type gitLogTool struct{ gitOps *workspace.GitOps }
|
||||
type gitLogTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitLogTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
@@ -171,7 +177,10 @@ func (t *gitLogTool) Execute(ctx context.Context, execCtx ExecutionContext, args
|
||||
|
||||
// ── git_branch ───────────────────────────────
|
||||
|
||||
type gitBranchTool struct{ gitOps *workspace.GitOps }
|
||||
type gitBranchTool struct {
|
||||
workspaceToolBase
|
||||
gitOps *workspace.GitOps
|
||||
}
|
||||
|
||||
func (t *gitBranchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -28,6 +28,7 @@ func RegisterKBSearch(stores store.Stores, embedder *knowledge.Embedder) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type kbSearchTool struct {
|
||||
BaseTool
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ func RegisterMemoryTools(stores store.Stores, embedder *knowledge.Embedder) {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type memorySaveTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -147,6 +148,7 @@ func (t *memorySaveTool) embedMemory(ctx context.Context, m *models.Memory, user
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type memoryRecallTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ func vectorToString(vec []float64) string {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteCreateTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -168,6 +169,7 @@ func (t *noteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteSearchTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -347,6 +349,7 @@ type noteSearchResult struct {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteUpdateTool struct {
|
||||
visitorDeniedBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
@@ -445,7 +448,7 @@ func (t *noteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// note_list
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type noteListTool struct{}
|
||||
type noteListTool struct{ visitorDeniedBase }
|
||||
|
||||
func (t *noteListTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
85
server/tools/predicates.go
Normal file
85
server/tools/predicates.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package tools
|
||||
|
||||
// ── Built-in Predicates (v0.25.0) ──────────
|
||||
//
|
||||
// Predicates determine tool availability based on runtime context.
|
||||
// Tools declare their availability by returning a Require from
|
||||
// Availability(). The registry evaluates predicates in AvailableFor()
|
||||
// before sending tool definitions to the LLM.
|
||||
//
|
||||
// AlwaysAvailable is defined in types.go (BaseTool references it).
|
||||
|
||||
// RequireWorkspace returns true when a workspace is bound to the
|
||||
// channel. Workspace and git tools use this.
|
||||
func RequireWorkspace(tc ToolContext) bool {
|
||||
return tc.WorkspaceID != ""
|
||||
}
|
||||
|
||||
// RequireWorkflow returns true when the channel is a workflow instance.
|
||||
// Workflow-specific tools (e.g. workflow_advance) use this.
|
||||
func RequireWorkflow(tc ToolContext) bool {
|
||||
return tc.WorkflowID != ""
|
||||
}
|
||||
|
||||
// RequireTeam returns true when the channel belongs to a team.
|
||||
func RequireTeam(tc ToolContext) bool {
|
||||
return tc.TeamID != ""
|
||||
}
|
||||
|
||||
// DenyVisitor returns true for authenticated users, false for anonymous
|
||||
// session participants (v0.24.3). Tools that expose personal data
|
||||
// (memory, notes) or perform privileged operations use this.
|
||||
func DenyVisitor(tc ToolContext) bool {
|
||||
return !tc.IsVisitor
|
||||
}
|
||||
|
||||
// All combines multiple predicates — all must pass for the tool to be
|
||||
// available. Short-circuits on first failure.
|
||||
func All(reqs ...Require) Require {
|
||||
return func(tc ToolContext) bool {
|
||||
for _, r := range reqs {
|
||||
if !r(tc) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Any combines multiple predicates — at least one must pass.
|
||||
// Short-circuits on first success.
|
||||
func Any(reqs ...Require) Require {
|
||||
return func(tc ToolContext) bool {
|
||||
for _, r := range reqs {
|
||||
if r(tc) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Not inverts a predicate.
|
||||
func Not(req Require) Require {
|
||||
return func(tc ToolContext) bool {
|
||||
return !req(tc)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared Base Types ───────────────────────
|
||||
// Embedded in tool structs to provide common availability predicates
|
||||
// without repeating Availability() overrides on every struct.
|
||||
|
||||
// workspaceToolBase provides RequireWorkspace + DenyVisitor availability.
|
||||
// Embed in workspace and git tool structs.
|
||||
type workspaceToolBase struct{ BaseTool }
|
||||
|
||||
var _requireWorkspaceAndDenyVisitor = All(RequireWorkspace, DenyVisitor)
|
||||
|
||||
func (workspaceToolBase) Availability() Require { return _requireWorkspaceAndDenyVisitor }
|
||||
|
||||
// visitorDeniedBase provides DenyVisitor availability.
|
||||
// Embed in memory and notes tool structs.
|
||||
type visitorDeniedBase struct{ BaseTool }
|
||||
|
||||
func (visitorDeniedBase) Availability() Require { return DenyVisitor }
|
||||
376
server/tools/predicates_test.go
Normal file
376
server/tools/predicates_test.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Predicate Tests ─────────────────────────
|
||||
|
||||
func TestAlwaysAvailable(t *testing.T) {
|
||||
cases := []ToolContext{
|
||||
{},
|
||||
{ChannelType: "direct"},
|
||||
{IsVisitor: true},
|
||||
{WorkspaceID: "ws-1", WorkflowID: "wf-1", TeamID: "t-1"},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
if !AlwaysAvailable(tc) {
|
||||
t.Errorf("case %d: AlwaysAvailable returned false", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireWorkspace(t *testing.T) {
|
||||
if RequireWorkspace(ToolContext{}) {
|
||||
t.Error("Expected false when WorkspaceID is empty")
|
||||
}
|
||||
if !RequireWorkspace(ToolContext{WorkspaceID: "ws-123"}) {
|
||||
t.Error("Expected true when WorkspaceID is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireWorkflow(t *testing.T) {
|
||||
if RequireWorkflow(ToolContext{}) {
|
||||
t.Error("Expected false when WorkflowID is empty")
|
||||
}
|
||||
if !RequireWorkflow(ToolContext{WorkflowID: "wf-456"}) {
|
||||
t.Error("Expected true when WorkflowID is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireTeam(t *testing.T) {
|
||||
if RequireTeam(ToolContext{}) {
|
||||
t.Error("Expected false when TeamID is empty")
|
||||
}
|
||||
if !RequireTeam(ToolContext{TeamID: "team-1"}) {
|
||||
t.Error("Expected true when TeamID is set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyVisitor(t *testing.T) {
|
||||
if !DenyVisitor(ToolContext{IsVisitor: false}) {
|
||||
t.Error("Expected true for authenticated user")
|
||||
}
|
||||
if DenyVisitor(ToolContext{IsVisitor: true}) {
|
||||
t.Error("Expected false for visitor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
wsAndNonVisitor := All(RequireWorkspace, DenyVisitor)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tc ToolContext
|
||||
expect bool
|
||||
}{
|
||||
{"both satisfied", ToolContext{WorkspaceID: "ws-1", IsVisitor: false}, true},
|
||||
{"no workspace", ToolContext{WorkspaceID: "", IsVisitor: false}, false},
|
||||
{"is visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
|
||||
{"neither satisfied", ToolContext{WorkspaceID: "", IsVisitor: true}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := wsAndNonVisitor(tc.tc); got != tc.expect {
|
||||
t.Errorf("got %v, want %v", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllEmpty(t *testing.T) {
|
||||
// All() with no predicates should pass (vacuous truth)
|
||||
pred := All()
|
||||
if !pred(ToolContext{}) {
|
||||
t.Error("All() with no predicates should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAny(t *testing.T) {
|
||||
wsOrWorkflow := Any(RequireWorkspace, RequireWorkflow)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tc ToolContext
|
||||
expect bool
|
||||
}{
|
||||
{"workspace only", ToolContext{WorkspaceID: "ws-1"}, true},
|
||||
{"workflow only", ToolContext{WorkflowID: "wf-1"}, true},
|
||||
{"both", ToolContext{WorkspaceID: "ws-1", WorkflowID: "wf-1"}, true},
|
||||
{"neither", ToolContext{}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := wsOrWorkflow(tc.tc); got != tc.expect {
|
||||
t.Errorf("got %v, want %v", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyEmpty(t *testing.T) {
|
||||
// Any() with no predicates should fail (no match)
|
||||
pred := Any()
|
||||
if pred(ToolContext{}) {
|
||||
t.Error("Any() with no predicates should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNot(t *testing.T) {
|
||||
notVisitor := Not(func(tc ToolContext) bool { return tc.IsVisitor })
|
||||
if !notVisitor(ToolContext{IsVisitor: false}) {
|
||||
t.Error("Not(visitor) should return true for non-visitor")
|
||||
}
|
||||
if notVisitor(ToolContext{IsVisitor: true}) {
|
||||
t.Error("Not(visitor) should return false for visitor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposedPredicates(t *testing.T) {
|
||||
// workspace_create: available when NO workspace AND not visitor
|
||||
workspaceCreate := All(Not(RequireWorkspace), DenyVisitor)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tc ToolContext
|
||||
expect bool
|
||||
}{
|
||||
{"no ws, authenticated", ToolContext{}, true},
|
||||
{"has ws, authenticated", ToolContext{WorkspaceID: "ws-1"}, false},
|
||||
{"no ws, visitor", ToolContext{IsVisitor: true}, false},
|
||||
{"has ws, visitor", ToolContext{WorkspaceID: "ws-1", IsVisitor: true}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := workspaceCreate(tc.tc); got != tc.expect {
|
||||
t.Errorf("got %v, want %v", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── BaseTool Tests ──────────────────────────
|
||||
|
||||
type testToolWithBase struct {
|
||||
BaseTool
|
||||
}
|
||||
|
||||
func (t *testToolWithBase) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "always_tool",
|
||||
Description: "test tool with BaseTool embed",
|
||||
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testToolWithBase) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
|
||||
func TestBaseToolSatisfiesContextualTool(t *testing.T) {
|
||||
tool := &testToolWithBase{}
|
||||
|
||||
// Must satisfy ContextualTool
|
||||
ct, ok := interface{}(tool).(ContextualTool)
|
||||
if !ok {
|
||||
t.Fatal("testToolWithBase should satisfy ContextualTool interface")
|
||||
}
|
||||
|
||||
// BaseTool.Availability() should return AlwaysAvailable
|
||||
if !ct.Availability()(ToolContext{IsVisitor: true}) {
|
||||
t.Error("BaseTool.Availability() should be AlwaysAvailable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── AvailableFor Tests ──────────────────────
|
||||
|
||||
// testContextTool overrides availability with a custom predicate.
|
||||
type testContextTool struct {
|
||||
BaseTool
|
||||
name string
|
||||
req Require
|
||||
}
|
||||
|
||||
func (t *testContextTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: t.name,
|
||||
Description: "test contextual tool: " + t.name,
|
||||
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testContextTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
|
||||
func (t *testContextTool) Availability() Require {
|
||||
return t.req
|
||||
}
|
||||
|
||||
// testPlainTool is a tool that does NOT implement ContextualTool —
|
||||
// only the base Tool interface. It should always be included.
|
||||
type testPlainTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t *testPlainTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: t.name,
|
||||
Description: "plain tool: " + t.name,
|
||||
Parameters: MustJSON(map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testPlainTool) Execute(_ context.Context, _ ExecutionContext, _ string) (string, error) {
|
||||
return `{"ok":true}`, nil
|
||||
}
|
||||
|
||||
func TestAvailableFor(t *testing.T) {
|
||||
// Save and restore global registry
|
||||
origRegistry := registry
|
||||
defer func() { registry = origRegistry }()
|
||||
|
||||
registry = map[string]Tool{
|
||||
"always_tool": &testToolWithBase{},
|
||||
"ws_tool": &testContextTool{name: "ws_tool", req: RequireWorkspace},
|
||||
"visitor_denied": &testContextTool{name: "visitor_denied", req: DenyVisitor},
|
||||
"ws_no_visitor": &testContextTool{name: "ws_no_visitor", req: All(RequireWorkspace, DenyVisitor)},
|
||||
"plain_tool": &testPlainTool{name: "plain_tool"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tctx ToolContext
|
||||
disabled map[string]bool
|
||||
expect []string // expected tool names (sorted doesn't matter, just set membership)
|
||||
}{
|
||||
{
|
||||
name: "empty context — only always + plain",
|
||||
tctx: ToolContext{},
|
||||
expect: []string{"always_tool", "visitor_denied", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "workspace context — ws tools available",
|
||||
tctx: ToolContext{WorkspaceID: "ws-1"},
|
||||
expect: []string{"always_tool", "ws_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "visitor with workspace — visitor_denied and ws_no_visitor excluded",
|
||||
tctx: ToolContext{WorkspaceID: "ws-1", IsVisitor: true},
|
||||
expect: []string{"always_tool", "ws_tool", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "disabled tool excluded",
|
||||
tctx: ToolContext{WorkspaceID: "ws-1"},
|
||||
disabled: map[string]bool{"ws_tool": true},
|
||||
expect: []string{"always_tool", "visitor_denied", "ws_no_visitor", "plain_tool"},
|
||||
},
|
||||
{
|
||||
name: "visitor no workspace — minimal set",
|
||||
tctx: ToolContext{IsVisitor: true},
|
||||
expect: []string{"always_tool", "plain_tool"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
disabled := tc.disabled
|
||||
if disabled == nil {
|
||||
disabled = map[string]bool{}
|
||||
}
|
||||
defs := AvailableFor(tc.tctx, disabled)
|
||||
|
||||
got := make(map[string]bool, len(defs))
|
||||
for _, d := range defs {
|
||||
got[d.Name] = true
|
||||
}
|
||||
|
||||
// Check expected tools are present
|
||||
for _, name := range tc.expect {
|
||||
if !got[name] {
|
||||
t.Errorf("expected tool %q not found in result", name)
|
||||
}
|
||||
}
|
||||
// Check no unexpected tools
|
||||
expect := make(map[string]bool, len(tc.expect))
|
||||
for _, name := range tc.expect {
|
||||
expect[name] = true
|
||||
}
|
||||
for name := range got {
|
||||
if !expect[name] {
|
||||
t.Errorf("unexpected tool %q in result", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableForBackwardCompat(t *testing.T) {
|
||||
// AvailableFor with empty ToolContext should behave like
|
||||
// the old AllDefinitionsFiltered for plain tools
|
||||
origRegistry := registry
|
||||
defer func() { registry = origRegistry }()
|
||||
|
||||
registry = map[string]Tool{
|
||||
"tool_a": &testPlainTool{name: "tool_a"},
|
||||
"tool_b": &testPlainTool{name: "tool_b"},
|
||||
"tool_c": &testPlainTool{name: "tool_c"},
|
||||
}
|
||||
|
||||
// No disabled, empty context — all plain tools should appear
|
||||
defs := AvailableFor(ToolContext{}, map[string]bool{})
|
||||
if len(defs) != 3 {
|
||||
t.Errorf("Expected 3 tools, got %d", len(defs))
|
||||
}
|
||||
|
||||
// Disable one
|
||||
defs = AvailableFor(ToolContext{}, map[string]bool{"tool_b": true})
|
||||
if len(defs) != 2 {
|
||||
t.Errorf("Expected 2 tools, got %d", len(defs))
|
||||
}
|
||||
for _, d := range defs {
|
||||
if d.Name == "tool_b" {
|
||||
t.Error("tool_b should be filtered out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ToolContext from ExecutionContext ────────
|
||||
|
||||
func TestToolContextFromExecContext(t *testing.T) {
|
||||
// Verify the fields map correctly between the two structs.
|
||||
// This is a compile-time/structural test — when ToolContext is
|
||||
// built from ExecutionContext in the completion handler, these
|
||||
// fields must align.
|
||||
exec := ExecutionContext{
|
||||
UserID: "u-1",
|
||||
ChannelID: "ch-1",
|
||||
PersonaID: "p-1",
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
TeamID: "t-1",
|
||||
}
|
||||
|
||||
// Simulate what the completion handler will do
|
||||
tc := ToolContext{
|
||||
WorkspaceID: exec.WorkspaceID,
|
||||
WorkflowID: exec.WorkflowID,
|
||||
TeamID: exec.TeamID,
|
||||
PersonaID: exec.PersonaID,
|
||||
// ChannelType and IsVisitor come from channel record / gin context
|
||||
}
|
||||
|
||||
if tc.WorkspaceID != "ws-1" {
|
||||
t.Errorf("WorkspaceID mismatch: %q", tc.WorkspaceID)
|
||||
}
|
||||
if tc.WorkflowID != "wf-1" {
|
||||
t.Errorf("WorkflowID mismatch: %q", tc.WorkflowID)
|
||||
}
|
||||
if tc.TeamID != "t-1" {
|
||||
t.Errorf("TeamID mismatch: %q", tc.TeamID)
|
||||
}
|
||||
if tc.PersonaID != "p-1" {
|
||||
t.Errorf("PersonaID mismatch: %q", tc.PersonaID)
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,31 @@ func AllDefinitions() []ToolDef {
|
||||
|
||||
// AllDefinitionsFiltered returns tool definitions excluding any names in the
|
||||
// disabled set. Used when the frontend sends a disabled_tools list.
|
||||
//
|
||||
// Deprecated: use AvailableFor() for context-aware filtering. This wrapper
|
||||
// remains for call sites that don't yet have a ToolContext.
|
||||
func AllDefinitionsFiltered(disabled map[string]bool) []ToolDef {
|
||||
if len(disabled) == 0 {
|
||||
return AllDefinitions()
|
||||
}
|
||||
return AvailableFor(ToolContext{}, disabled)
|
||||
}
|
||||
|
||||
// AvailableFor returns tool definitions available in the given context,
|
||||
// excluding any names in the disabled set. Tools that implement
|
||||
// ContextualTool have their Availability() predicate evaluated against
|
||||
// tctx. Tools that only implement Tool (no predicate) are always included.
|
||||
func AvailableFor(tctx ToolContext, disabled map[string]bool) []ToolDef {
|
||||
defs := make([]ToolDef, 0, len(registry))
|
||||
for _, t := range registry {
|
||||
def := t.Definition()
|
||||
if !disabled[def.Name] {
|
||||
defs = append(defs, def)
|
||||
if disabled[def.Name] {
|
||||
continue
|
||||
}
|
||||
// Check context-aware availability if the tool declares it
|
||||
if ct, ok := t.(ContextualTool); ok {
|
||||
if !ct.Availability()(tctx) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
defs = append(defs, def)
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
@@ -39,12 +39,16 @@ type ToolResult struct {
|
||||
|
||||
// ── Tool Interface ──────────────────────────
|
||||
|
||||
// ExecutionContext provides tool implementations with the info they need.
|
||||
// ExecutionContext provides tool implementations with the info they need
|
||||
// at execution time. Populated from channel/request state in the
|
||||
// completion handler.
|
||||
type ExecutionContext struct {
|
||||
UserID string
|
||||
ChannelID string
|
||||
PersonaID string // set when a persona is active — tools use for scoped KB access
|
||||
WorkspaceID string // set when channel/project has a workspace bound
|
||||
WorkflowID string // v0.25.0: set when channel is a workflow instance
|
||||
TeamID string // v0.25.0: set when channel belongs to a team
|
||||
}
|
||||
|
||||
// Tool is the interface every built-in tool implements.
|
||||
@@ -57,6 +61,46 @@ type Tool interface {
|
||||
Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error)
|
||||
}
|
||||
|
||||
// ── Context-Aware Tool System (v0.25.0) ────
|
||||
|
||||
// ToolContext describes the runtime environment for tool availability
|
||||
// checks. Built from channel metadata in the completion handler —
|
||||
// separate from ExecutionContext because availability is checked before
|
||||
// execution.
|
||||
type ToolContext struct {
|
||||
ChannelType string // "direct", "dm", "group", "channel", "workflow"
|
||||
WorkspaceID string // non-empty when channel/project has a workspace
|
||||
WorkflowID string // non-empty when channel is a workflow instance
|
||||
TeamID string // non-empty when channel belongs to a team
|
||||
PersonaID string // non-empty when a persona is active
|
||||
IsVisitor bool // true for session_participants (v0.24.3)
|
||||
}
|
||||
|
||||
// Require is a predicate that determines if a tool is available in a
|
||||
// given context. Return true = tool is available, false = suppressed.
|
||||
type Require func(ToolContext) bool
|
||||
|
||||
// ContextualTool extends Tool with an availability predicate. Tools
|
||||
// that embed BaseTool get AlwaysAvailable by default. Tools that need
|
||||
// context-dependent visibility override Availability().
|
||||
type ContextualTool interface {
|
||||
Tool
|
||||
Availability() Require
|
||||
}
|
||||
|
||||
// BaseTool provides default availability (always available). Embed in
|
||||
// any tool struct for backward compat — no behavioral change.
|
||||
type BaseTool struct{}
|
||||
|
||||
// Availability returns AlwaysAvailable. Override in tool structs that
|
||||
// need context-dependent visibility.
|
||||
func (BaseTool) Availability() Require { return AlwaysAvailable }
|
||||
|
||||
// AlwaysAvailable is the default predicate — tool is available in
|
||||
// every context. Defined here (not in predicates.go) because BaseTool
|
||||
// references it.
|
||||
func AlwaysAvailable(_ ToolContext) bool { return true }
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// MustJSON marshals v to a json.RawMessage, panicking on error.
|
||||
|
||||
@@ -22,7 +22,7 @@ func init() {
|
||||
// url_fetch
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type URLFetchTool struct{}
|
||||
type URLFetchTool struct{ BaseTool }
|
||||
|
||||
func (t *URLFetchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -16,7 +16,7 @@ func init() {
|
||||
// web_search
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type WebSearchTool struct{}
|
||||
type WebSearchTool struct{ BaseTool }
|
||||
|
||||
func (t *WebSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
|
||||
@@ -34,16 +34,6 @@ func RegisterWorkspaceSearchTool(stores store.Stores, embedder *knowledge.Embedd
|
||||
Register(&workspaceSearchTool{stores: stores, embedder: embedder})
|
||||
}
|
||||
|
||||
// WorkspaceToolNames returns the names of all workspace tools.
|
||||
// Used by the completion handler to filter them out when no workspace is bound.
|
||||
func WorkspaceToolNames() []string {
|
||||
return []string{
|
||||
"workspace_ls", "workspace_read", "workspace_write",
|
||||
"workspace_rm", "workspace_mv", "workspace_patch",
|
||||
"workspace_search",
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared ─────────────────────────────────
|
||||
|
||||
// loadWorkspace resolves the workspace from the execution context.
|
||||
@@ -70,6 +60,7 @@ const (
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceLsTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -136,6 +127,7 @@ func (t *workspaceLsTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceReadTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -242,6 +234,7 @@ func isTextContentType(ct string) bool {
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceWriteTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -296,6 +289,7 @@ func (t *workspaceWriteTool) Execute(ctx context.Context, execCtx ExecutionConte
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceRmTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -348,6 +342,7 @@ func (t *workspaceRmTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspaceMvTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
@@ -401,6 +396,7 @@ func (t *workspaceMvTool) Execute(ctx context.Context, execCtx ExecutionContext,
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type workspacePatchTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
wfs *workspace.FS
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// ── workspace_search ────────────────────────
|
||||
|
||||
type workspaceSearchTool struct {
|
||||
workspaceToolBase
|
||||
stores store.Stores
|
||||
embedder *knowledge.Embedder
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user