Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/tools/doc.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
// Package tools defines tool registries and integrations (MCP, HTTP, native).
//
// [Registry] resolves tool.<name>.<operation> uses strings and dispatches MVP native and mock tools.
// Responses use [ToolCallResponse] with output + meta per §13.2.
package tools
13 changes: 13 additions & 0 deletions internal/tools/errors.go
Original file line number Diff line number Diff line change
@@ -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)
}
25 changes: 25 additions & 0 deletions internal/tools/mock.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions internal/tools/native/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package native implements built-in native tool operations (echo, identity) for demos and tests.
package native
54 changes: 54 additions & 0 deletions internal/tools/native/registry.go
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions internal/tools/normalize.go
Original file line number Diff line number Diff line change
@@ -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}
}
93 changes: 93 additions & 0 deletions internal/tools/registry.go
Original file line number Diff line number Diff line change
@@ -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.<name>.<operation>", uses)
}
toolName = rest[:i]
operation = rest[i+1:]
if strings.TrimSpace(toolName) == "" || strings.TrimSpace(operation) == "" {
return "", "", fmt.Errorf("tools: uses %q must be tool.<name>.<operation>", 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)
}
}
93 changes: 93 additions & 0 deletions internal/tools/tools_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 26 additions & 0 deletions internal/tools/types.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading