From a4ff5d730c654ad2b3a818e76f3ace5e481bed69 Mon Sep 17 00:00:00 2001 From: Leonardo Araujo Date: Sat, 11 Apr 2026 18:40:47 -0300 Subject: [PATCH] feat(tools): ToolExecutor registry, mock, and native tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ToolExecutor, ToolCallRequest/Response (output + meta §13.2), ParseUses for tool.., Registry dispatching native and mock tool types, MockExecutor, and native echo/identity operations. Unknown native operations return *UnknownOperationError for structured handling (issue #18). Made-with: Cursor --- internal/tools/doc.go | 3 + internal/tools/errors.go | 13 +++++ internal/tools/mock.go | 25 +++++++++ internal/tools/native/doc.go | 2 + internal/tools/native/registry.go | 54 ++++++++++++++++++ internal/tools/normalize.go | 14 +++++ internal/tools/registry.go | 93 +++++++++++++++++++++++++++++++ internal/tools/tools_test.go | 93 +++++++++++++++++++++++++++++++ internal/tools/types.go | 26 +++++++++ 9 files changed, 323 insertions(+) create mode 100644 internal/tools/errors.go create mode 100644 internal/tools/mock.go create mode 100644 internal/tools/native/doc.go create mode 100644 internal/tools/native/registry.go create mode 100644 internal/tools/normalize.go create mode 100644 internal/tools/registry.go create mode 100644 internal/tools/tools_test.go create mode 100644 internal/tools/types.go diff --git a/internal/tools/doc.go b/internal/tools/doc.go index 1badd37..d01fe47 100644 --- a/internal/tools/doc.go +++ b/internal/tools/doc.go @@ -1,2 +1,5 @@ // Package tools defines tool registries and integrations (MCP, HTTP, native). +// +// [Registry] resolves tool.. uses strings and dispatches MVP native and mock tools. +// Responses use [ToolCallResponse] with output + meta per §13.2. package tools diff --git a/internal/tools/errors.go b/internal/tools/errors.go new file mode 100644 index 0000000..da7d437 --- /dev/null +++ b/internal/tools/errors.go @@ -0,0 +1,13 @@ +package tools + +import "fmt" + +// UnknownOperationError is returned when a native (or registered) tool does not implement the operation. +type UnknownOperationError struct { + Tool string + Operation string +} + +func (e *UnknownOperationError) Error() string { + return fmt.Sprintf("tools: unknown operation %q for tool %q", e.Operation, e.Tool) +} diff --git a/internal/tools/mock.go b/internal/tools/mock.go new file mode 100644 index 0000000..a7c0bbc --- /dev/null +++ b/internal/tools/mock.go @@ -0,0 +1,25 @@ +package tools + +import "context" + +// MockExecutor returns a fixed response (or Fn) for tests. +type MockExecutor struct { + Resp ToolCallResponse + Err error + Fn func(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error) +} + +// Call implements [ToolExecutor]. +func (m *MockExecutor) Call(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error) { + if m.Fn != nil { + return m.Fn(ctx, req) + } + if m.Err != nil { + return ToolCallResponse{}, m.Err + } + out := m.Resp.Output + if out == nil { + out = map[string]any{} + } + return ToolCallResponse{Output: out, Meta: m.Resp.Meta}, nil +} diff --git a/internal/tools/native/doc.go b/internal/tools/native/doc.go new file mode 100644 index 0000000..6f514af --- /dev/null +++ b/internal/tools/native/doc.go @@ -0,0 +1,2 @@ +// Package native implements built-in native tool operations (echo, identity) for demos and tests. +package native diff --git a/internal/tools/native/registry.go b/internal/tools/native/registry.go new file mode 100644 index 0000000..adc231c --- /dev/null +++ b/internal/tools/native/registry.go @@ -0,0 +1,54 @@ +package native + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ErrUnknownOperation indicates the operation name is not implemented by this registry. +var ErrUnknownOperation = errors.New("native: unknown operation") + +// ExecMeta is timing/cost metadata for a native call (§13.2). +type ExecMeta struct { + DurationMs int64 + CostUSD float64 +} + +// Registry dispatches built-in native tool operations (issue #18). +type Registry struct{} + +// NewRegistry returns a registry with echo and identity operations. +func NewRegistry() *Registry { + return &Registry{} +} + +// Dispatch runs a single operation for a native-typed tool. with is the workflow step input map. +func (r *Registry) Dispatch(ctx context.Context, operation string, with map[string]any) (map[string]any, ExecMeta, error) { + _ = ctx + start := time.Now() + meta := ExecMeta{CostUSD: 0} + switch operation { + case "echo": + meta.DurationMs = time.Since(start).Milliseconds() + return map[string]any{"echo": shallowCopy(with)}, meta, nil + case "identity": + v, ok := with["value"] + meta.DurationMs = time.Since(start).Milliseconds() + return map[string]any{"value": v, "ok": ok}, meta, nil + default: + return nil, ExecMeta{}, fmt.Errorf("%w: %q", ErrUnknownOperation, operation) + } +} + +func shallowCopy(m map[string]any) map[string]any { + if m == nil { + return map[string]any{} + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} diff --git a/internal/tools/normalize.go b/internal/tools/normalize.go new file mode 100644 index 0000000..64a8ef1 --- /dev/null +++ b/internal/tools/normalize.go @@ -0,0 +1,14 @@ +package tools + +import "time" + +// normalizeResponse ensures non-nil Output and fills default Meta when zero. +func normalizeResponse(output map[string]any, meta ToolCallMeta, start time.Time) ToolCallResponse { + if output == nil { + output = map[string]any{} + } + if meta.DurationMs == 0 && !start.IsZero() { + meta.DurationMs = time.Since(start).Milliseconds() + } + return ToolCallResponse{Output: output, Meta: meta} +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go new file mode 100644 index 0000000..cfbc027 --- /dev/null +++ b/internal/tools/registry.go @@ -0,0 +1,93 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools/native" +) + +// Registry resolves workflow uses strings against declared tools and dispatches by transport (MVP: native, mock). +type Registry struct { + graph *spec.ProjectGraph + native *native.Registry + // Mock is optional; when set, ToolSpec type "mock" delegates here. Otherwise a canned JSON body is returned. + Mock ToolExecutor +} + +// NewRegistry builds a registry from the merged project graph. +func NewRegistry(g *spec.ProjectGraph) *Registry { + return &Registry{ + graph: g, + native: native.NewRegistry(), + } +} + +// ParseUses splits tool.github.pull_request.get into tool name "github" and operation "pull_request.get". +func ParseUses(uses string) (toolName string, operation string, err error) { + uses = strings.TrimSpace(uses) + const prefix = "tool." + if !strings.HasPrefix(uses, prefix) { + return "", "", fmt.Errorf("tools: uses %q must start with %q", uses, prefix) + } + rest := strings.TrimPrefix(uses, prefix) + i := strings.IndexByte(rest, '.') + if i <= 0 || i >= len(rest)-1 { + return "", "", fmt.Errorf("tools: uses %q must be tool..", uses) + } + toolName = rest[:i] + operation = rest[i+1:] + if strings.TrimSpace(toolName) == "" || strings.TrimSpace(operation) == "" { + return "", "", fmt.Errorf("tools: uses %q must be tool..", uses) + } + return toolName, operation, nil +} + +// Call implements [ToolExecutor] by resolving Uses against the project graph. +func (r *Registry) Call(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error) { + if r == nil { + return ToolCallResponse{}, fmt.Errorf("tools: nil registry") + } + start := time.Now() + toolName, operation, err := ParseUses(req.Uses) + if err != nil { + return ToolCallResponse{}, err + } + if r.graph == nil || r.graph.Tools == nil { + return ToolCallResponse{}, fmt.Errorf("tools: unknown tool %q", toolName) + } + tr, ok := r.graph.Tools[toolName] + if !ok || tr == nil { + return ToolCallResponse{}, fmt.Errorf("tools: unknown tool %q", toolName) + } + typ := strings.ToLower(strings.TrimSpace(tr.Spec.Type)) + switch typ { + case "native": + if r.native == nil { + r.native = native.NewRegistry() + } + out, meta, err := r.native.Dispatch(ctx, operation, req.With) + if err != nil { + if errors.Is(err, native.ErrUnknownOperation) { + return ToolCallResponse{}, &UnknownOperationError{Tool: toolName, Operation: operation} + } + return ToolCallResponse{}, err + } + return normalizeResponse(out, ToolCallMeta{DurationMs: meta.DurationMs, CostUSD: meta.CostUSD}, start), nil + case "mock": + if r.Mock != nil { + return r.Mock.Call(ctx, req) + } + return normalizeResponse( + map[string]any{"message": "mock", "uses": req.Uses}, + ToolCallMeta{DurationMs: 1, CostUSD: 0}, + start, + ), nil + default: + return ToolCallResponse{}, fmt.Errorf("tools: tool %q type %q not supported by MVP registry (native|mock only)", toolName, tr.Spec.Type) + } +} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go new file mode 100644 index 0000000..064434d --- /dev/null +++ b/internal/tools/tools_test.go @@ -0,0 +1,93 @@ +package tools + +import ( + "context" + "errors" + "testing" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +func testGraphNativeTool() *spec.ProjectGraph { + return &spec.ProjectGraph{ + Tools: map[string]*spec.ToolResource{ + "demo": { + APIVersion: spec.APIVersionV0, + Kind: spec.KindTool, + Metadata: spec.Metadata{Name: "demo"}, + Spec: spec.ToolSpec{Type: "native"}, + }, + }, + } +} + +func TestRegistry_nativeEcho_succeeds(t *testing.T) { + ctx := context.Background() + reg := NewRegistry(testGraphNativeTool()) + resp, err := reg.Call(ctx, ToolCallRequest{ + Uses: "tool.demo.echo", + With: map[string]any{"repo": "acme/api", "number": float64(7)}, + }) + if err != nil { + t.Fatal(err) + } + if resp.Output == nil { + t.Fatal("nil output") + } + echo, ok := resp.Output["echo"].(map[string]any) + if !ok { + t.Fatalf("echo field: %#v", resp.Output["echo"]) + } + if echo["repo"] != "acme/api" || echo["number"] != float64(7) { + t.Fatalf("echo payload %+v", echo) + } + if resp.Meta.DurationMs < 0 { + t.Fatalf("meta %+v", resp.Meta) + } +} + +func TestRegistry_unknownOperation_structuredError(t *testing.T) { + ctx := context.Background() + reg := NewRegistry(testGraphNativeTool()) + _, err := reg.Call(ctx, ToolCallRequest{ + Uses: "tool.demo.no_such_op", + With: map[string]any{}, + }) + if err == nil { + t.Fatal("expected error") + } + var u *UnknownOperationError + if !errors.As(err, &u) { + t.Fatalf("want *UnknownOperationError, got %T: %v", err, err) + } + if u.Tool != "demo" || u.Operation != "no_such_op" { + t.Fatalf("got %+v", u) + } +} + +func TestParseUses_githubExample(t *testing.T) { + tool, op, err := ParseUses("tool.github.pull_request.get") + if err != nil { + t.Fatal(err) + } + if tool != "github" || op != "pull_request.get" { + t.Fatalf("%q %q", tool, op) + } +} + +func TestMockExecutor_isolated(t *testing.T) { + ctx := context.Background() + m := &MockExecutor{ + Resp: ToolCallResponse{ + Output: map[string]any{"x": float64(1)}, + Meta: ToolCallMeta{DurationMs: 9, CostUSD: 0.01}, + }, + } + resp, err := m.Call(ctx, ToolCallRequest{Uses: "tool.any.x", With: nil}) + if err != nil { + t.Fatal(err) + } + if resp.Output["x"] != float64(1) { + t.Fatalf("%+v", resp.Output) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go new file mode 100644 index 0000000..f113b8b --- /dev/null +++ b/internal/tools/types.go @@ -0,0 +1,26 @@ +package tools + +import "context" + +// ToolExecutor runs one tool operation (design doc §12.2 G). +type ToolExecutor interface { + Call(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error) +} + +// ToolCallRequest is a resolved workflow tool step (uses + with). +type ToolCallRequest struct { + Uses string + With map[string]any +} + +// ToolCallResponse matches the MVP step result envelope (§13.2): output + meta. +type ToolCallResponse struct { + Output map[string]any + Meta ToolCallMeta +} + +// ToolCallMeta holds placeholder timing and cost (§13.2). +type ToolCallMeta struct { + DurationMs int64 + CostUSD float64 +}