Skip to content

Commit a4ff5d7

Browse files
committed
feat(tools): ToolExecutor registry, mock, and native tools
Add ToolExecutor, ToolCallRequest/Response (output + meta §13.2), ParseUses for tool.<name>.<operation>, 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
1 parent 3f8788d commit a4ff5d7

9 files changed

Lines changed: 323 additions & 0 deletions

File tree

internal/tools/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
// Package tools defines tool registries and integrations (MCP, HTTP, native).
2+
//
3+
// [Registry] resolves tool.<name>.<operation> uses strings and dispatches MVP native and mock tools.
4+
// Responses use [ToolCallResponse] with output + meta per §13.2.
25
package tools

internal/tools/errors.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package tools
2+
3+
import "fmt"
4+
5+
// UnknownOperationError is returned when a native (or registered) tool does not implement the operation.
6+
type UnknownOperationError struct {
7+
Tool string
8+
Operation string
9+
}
10+
11+
func (e *UnknownOperationError) Error() string {
12+
return fmt.Sprintf("tools: unknown operation %q for tool %q", e.Operation, e.Tool)
13+
}

internal/tools/mock.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package tools
2+
3+
import "context"
4+
5+
// MockExecutor returns a fixed response (or Fn) for tests.
6+
type MockExecutor struct {
7+
Resp ToolCallResponse
8+
Err error
9+
Fn func(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error)
10+
}
11+
12+
// Call implements [ToolExecutor].
13+
func (m *MockExecutor) Call(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error) {
14+
if m.Fn != nil {
15+
return m.Fn(ctx, req)
16+
}
17+
if m.Err != nil {
18+
return ToolCallResponse{}, m.Err
19+
}
20+
out := m.Resp.Output
21+
if out == nil {
22+
out = map[string]any{}
23+
}
24+
return ToolCallResponse{Output: out, Meta: m.Resp.Meta}, nil
25+
}

internal/tools/native/doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Package native implements built-in native tool operations (echo, identity) for demos and tests.
2+
package native

internal/tools/native/registry.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package native
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"time"
8+
)
9+
10+
// ErrUnknownOperation indicates the operation name is not implemented by this registry.
11+
var ErrUnknownOperation = errors.New("native: unknown operation")
12+
13+
// ExecMeta is timing/cost metadata for a native call (§13.2).
14+
type ExecMeta struct {
15+
DurationMs int64
16+
CostUSD float64
17+
}
18+
19+
// Registry dispatches built-in native tool operations (issue #18).
20+
type Registry struct{}
21+
22+
// NewRegistry returns a registry with echo and identity operations.
23+
func NewRegistry() *Registry {
24+
return &Registry{}
25+
}
26+
27+
// Dispatch runs a single operation for a native-typed tool. with is the workflow step input map.
28+
func (r *Registry) Dispatch(ctx context.Context, operation string, with map[string]any) (map[string]any, ExecMeta, error) {
29+
_ = ctx
30+
start := time.Now()
31+
meta := ExecMeta{CostUSD: 0}
32+
switch operation {
33+
case "echo":
34+
meta.DurationMs = time.Since(start).Milliseconds()
35+
return map[string]any{"echo": shallowCopy(with)}, meta, nil
36+
case "identity":
37+
v, ok := with["value"]
38+
meta.DurationMs = time.Since(start).Milliseconds()
39+
return map[string]any{"value": v, "ok": ok}, meta, nil
40+
default:
41+
return nil, ExecMeta{}, fmt.Errorf("%w: %q", ErrUnknownOperation, operation)
42+
}
43+
}
44+
45+
func shallowCopy(m map[string]any) map[string]any {
46+
if m == nil {
47+
return map[string]any{}
48+
}
49+
out := make(map[string]any, len(m))
50+
for k, v := range m {
51+
out[k] = v
52+
}
53+
return out
54+
}

internal/tools/normalize.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package tools
2+
3+
import "time"
4+
5+
// normalizeResponse ensures non-nil Output and fills default Meta when zero.
6+
func normalizeResponse(output map[string]any, meta ToolCallMeta, start time.Time) ToolCallResponse {
7+
if output == nil {
8+
output = map[string]any{}
9+
}
10+
if meta.DurationMs == 0 && !start.IsZero() {
11+
meta.DurationMs = time.Since(start).Milliseconds()
12+
}
13+
return ToolCallResponse{Output: output, Meta: meta}
14+
}

internal/tools/registry.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools/native"
12+
)
13+
14+
// Registry resolves workflow uses strings against declared tools and dispatches by transport (MVP: native, mock).
15+
type Registry struct {
16+
graph *spec.ProjectGraph
17+
native *native.Registry
18+
// Mock is optional; when set, ToolSpec type "mock" delegates here. Otherwise a canned JSON body is returned.
19+
Mock ToolExecutor
20+
}
21+
22+
// NewRegistry builds a registry from the merged project graph.
23+
func NewRegistry(g *spec.ProjectGraph) *Registry {
24+
return &Registry{
25+
graph: g,
26+
native: native.NewRegistry(),
27+
}
28+
}
29+
30+
// ParseUses splits tool.github.pull_request.get into tool name "github" and operation "pull_request.get".
31+
func ParseUses(uses string) (toolName string, operation string, err error) {
32+
uses = strings.TrimSpace(uses)
33+
const prefix = "tool."
34+
if !strings.HasPrefix(uses, prefix) {
35+
return "", "", fmt.Errorf("tools: uses %q must start with %q", uses, prefix)
36+
}
37+
rest := strings.TrimPrefix(uses, prefix)
38+
i := strings.IndexByte(rest, '.')
39+
if i <= 0 || i >= len(rest)-1 {
40+
return "", "", fmt.Errorf("tools: uses %q must be tool.<name>.<operation>", uses)
41+
}
42+
toolName = rest[:i]
43+
operation = rest[i+1:]
44+
if strings.TrimSpace(toolName) == "" || strings.TrimSpace(operation) == "" {
45+
return "", "", fmt.Errorf("tools: uses %q must be tool.<name>.<operation>", uses)
46+
}
47+
return toolName, operation, nil
48+
}
49+
50+
// Call implements [ToolExecutor] by resolving Uses against the project graph.
51+
func (r *Registry) Call(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error) {
52+
if r == nil {
53+
return ToolCallResponse{}, fmt.Errorf("tools: nil registry")
54+
}
55+
start := time.Now()
56+
toolName, operation, err := ParseUses(req.Uses)
57+
if err != nil {
58+
return ToolCallResponse{}, err
59+
}
60+
if r.graph == nil || r.graph.Tools == nil {
61+
return ToolCallResponse{}, fmt.Errorf("tools: unknown tool %q", toolName)
62+
}
63+
tr, ok := r.graph.Tools[toolName]
64+
if !ok || tr == nil {
65+
return ToolCallResponse{}, fmt.Errorf("tools: unknown tool %q", toolName)
66+
}
67+
typ := strings.ToLower(strings.TrimSpace(tr.Spec.Type))
68+
switch typ {
69+
case "native":
70+
if r.native == nil {
71+
r.native = native.NewRegistry()
72+
}
73+
out, meta, err := r.native.Dispatch(ctx, operation, req.With)
74+
if err != nil {
75+
if errors.Is(err, native.ErrUnknownOperation) {
76+
return ToolCallResponse{}, &UnknownOperationError{Tool: toolName, Operation: operation}
77+
}
78+
return ToolCallResponse{}, err
79+
}
80+
return normalizeResponse(out, ToolCallMeta{DurationMs: meta.DurationMs, CostUSD: meta.CostUSD}, start), nil
81+
case "mock":
82+
if r.Mock != nil {
83+
return r.Mock.Call(ctx, req)
84+
}
85+
return normalizeResponse(
86+
map[string]any{"message": "mock", "uses": req.Uses},
87+
ToolCallMeta{DurationMs: 1, CostUSD: 0},
88+
start,
89+
), nil
90+
default:
91+
return ToolCallResponse{}, fmt.Errorf("tools: tool %q type %q not supported by MVP registry (native|mock only)", toolName, tr.Spec.Type)
92+
}
93+
}

internal/tools/tools_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
9+
)
10+
11+
func testGraphNativeTool() *spec.ProjectGraph {
12+
return &spec.ProjectGraph{
13+
Tools: map[string]*spec.ToolResource{
14+
"demo": {
15+
APIVersion: spec.APIVersionV0,
16+
Kind: spec.KindTool,
17+
Metadata: spec.Metadata{Name: "demo"},
18+
Spec: spec.ToolSpec{Type: "native"},
19+
},
20+
},
21+
}
22+
}
23+
24+
func TestRegistry_nativeEcho_succeeds(t *testing.T) {
25+
ctx := context.Background()
26+
reg := NewRegistry(testGraphNativeTool())
27+
resp, err := reg.Call(ctx, ToolCallRequest{
28+
Uses: "tool.demo.echo",
29+
With: map[string]any{"repo": "acme/api", "number": float64(7)},
30+
})
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
if resp.Output == nil {
35+
t.Fatal("nil output")
36+
}
37+
echo, ok := resp.Output["echo"].(map[string]any)
38+
if !ok {
39+
t.Fatalf("echo field: %#v", resp.Output["echo"])
40+
}
41+
if echo["repo"] != "acme/api" || echo["number"] != float64(7) {
42+
t.Fatalf("echo payload %+v", echo)
43+
}
44+
if resp.Meta.DurationMs < 0 {
45+
t.Fatalf("meta %+v", resp.Meta)
46+
}
47+
}
48+
49+
func TestRegistry_unknownOperation_structuredError(t *testing.T) {
50+
ctx := context.Background()
51+
reg := NewRegistry(testGraphNativeTool())
52+
_, err := reg.Call(ctx, ToolCallRequest{
53+
Uses: "tool.demo.no_such_op",
54+
With: map[string]any{},
55+
})
56+
if err == nil {
57+
t.Fatal("expected error")
58+
}
59+
var u *UnknownOperationError
60+
if !errors.As(err, &u) {
61+
t.Fatalf("want *UnknownOperationError, got %T: %v", err, err)
62+
}
63+
if u.Tool != "demo" || u.Operation != "no_such_op" {
64+
t.Fatalf("got %+v", u)
65+
}
66+
}
67+
68+
func TestParseUses_githubExample(t *testing.T) {
69+
tool, op, err := ParseUses("tool.github.pull_request.get")
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
if tool != "github" || op != "pull_request.get" {
74+
t.Fatalf("%q %q", tool, op)
75+
}
76+
}
77+
78+
func TestMockExecutor_isolated(t *testing.T) {
79+
ctx := context.Background()
80+
m := &MockExecutor{
81+
Resp: ToolCallResponse{
82+
Output: map[string]any{"x": float64(1)},
83+
Meta: ToolCallMeta{DurationMs: 9, CostUSD: 0.01},
84+
},
85+
}
86+
resp, err := m.Call(ctx, ToolCallRequest{Uses: "tool.any.x", With: nil})
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
if resp.Output["x"] != float64(1) {
91+
t.Fatalf("%+v", resp.Output)
92+
}
93+
}

internal/tools/types.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package tools
2+
3+
import "context"
4+
5+
// ToolExecutor runs one tool operation (design doc §12.2 G).
6+
type ToolExecutor interface {
7+
Call(ctx context.Context, req ToolCallRequest) (ToolCallResponse, error)
8+
}
9+
10+
// ToolCallRequest is a resolved workflow tool step (uses + with).
11+
type ToolCallRequest struct {
12+
Uses string
13+
With map[string]any
14+
}
15+
16+
// ToolCallResponse matches the MVP step result envelope (§13.2): output + meta.
17+
type ToolCallResponse struct {
18+
Output map[string]any
19+
Meta ToolCallMeta
20+
}
21+
22+
// ToolCallMeta holds placeholder timing and cost (§13.2).
23+
type ToolCallMeta struct {
24+
DurationMs int64
25+
CostUSD float64
26+
}

0 commit comments

Comments
 (0)