Skip to content

chore(codersdk/toolsdk): improve static analyzability of toolsdk.Tools #17562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 29, 2025

Conversation

johnstcn
Copy link
Member

@johnstcn johnstcn commented Apr 24, 2025

  • Refactors toolsdk.Tools to remove opaque map[string]any argument in favour of typed args structs.
  • Refactors toolsdk.Tools to remove opaque passing of dependencies via context.Context in favour of a tool dependencies struct.
  • Adds panic recovery and clean context middleware to all tools.
  • Adds GenericTool implementation to allow keeping toolsdk.All with uniform type signature while maintaining type information in handlers.
  • Adds stricter checks to patchWorkspaceAgentAppStatus handler.

@johnstcn johnstcn self-assigned this Apr 24, 2025
@johnstcn johnstcn changed the title chore(codersdk/toolsdk): add typed argument to toolsdk.Tool chore(codersdk/toolsdk): improve static analyzability of toolsdk.Tools Apr 24, 2025
@johnstcn johnstcn marked this pull request as ready for review April 24, 2025 15:21
@johnstcn johnstcn requested a review from kylecarbs April 24, 2025 15:21
Comment on lines 348 to 371
if len(req.Message) > 160 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Message is too long.",
Detail: "Message must be less than 160 characters.",
Validations: []codersdk.ValidationError{
{Field: "message", Detail: "Message must be less than 160 characters."},
},
})
return
}

switch req.State {
case codersdk.WorkspaceAppStatusStateComplete, codersdk.WorkspaceAppStatusStateFailure, codersdk.WorkspaceAppStatusStateWorking: // valid states
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid state provided.",
Detail: fmt.Sprintf("invalid state: %q", req.State),
Validations: []codersdk.ValidationError{
{Field: "state", Detail: "State must be one of: complete, failure, working."},
},
})
return
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: moved this check to the handler instead.

@johnstcn johnstcn force-pushed the cj/toolsdk-refactor branch from 6c97ac4 to 4a9ab00 Compare April 25, 2025 13:43
Comment on lines 34 to 53
// Generic returns a type-erased version of a TypedTool where the arguments and
// return values are converted to/from json.RawMessage.
// This allows the tool to be referenced without knowing the concrete arguments
// or return values. The original TypedHandlerFunc is wrapped to handle type
// conversion.
func (t Tool[Arg, Ret]) Generic() GenericTool {
return GenericTool{
Tool: t.Tool,
Handler: func(ctx context.Context, args map[string]any) (any, error) {
return t.Handler(ctx, args)
},
Handler: wrap(func(ctx context.Context, tb Deps, args json.RawMessage) (json.RawMessage, error) {
var typedArgs Arg
if err := json.Unmarshal(args, &typedArgs); err != nil {
return nil, xerrors.Errorf("failed to unmarshal args: %w", err)
}
ret, err := t.Handler(ctx, tb, typedArgs)
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(ret); err != nil {
return json.RawMessage{}, err
}
return buf.Bytes(), err
}, WithCleanContext, WithRecover),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: this was the best way I could find to maintain type safety while allowing external callers to treat all the tools equivalently.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other option would be to attempt to fill out the Arg via reflection, taking a map[string]any as "raw" input. Would definitely be more code, but might be a performance optimization in future for the MCP server which already parses the JSON of the tool invocation.

@johnstcn johnstcn requested a review from spikecurtis April 29, 2025 08:20
var (
// All is a list of all tools that can be used in the Coder CLI.
// When you add a new tool, be sure to include it here!
All = []Tool[any]{
CreateTemplateVersion.Generic(),
All = []GenericTool{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be useful to have different lists depending on whether you have an agent client and slug.

Comment on lines 34 to 53
// Generic returns a type-erased version of a TypedTool where the arguments and
// return values are converted to/from json.RawMessage.
// This allows the tool to be referenced without knowing the concrete arguments
// or return values. The original TypedHandlerFunc is wrapped to handle type
// conversion.
func (t Tool[Arg, Ret]) Generic() GenericTool {
return GenericTool{
Tool: t.Tool,
Handler: func(ctx context.Context, args map[string]any) (any, error) {
return t.Handler(ctx, args)
},
Handler: wrap(func(ctx context.Context, tb Deps, args json.RawMessage) (json.RawMessage, error) {
var typedArgs Arg
if err := json.Unmarshal(args, &typedArgs); err != nil {
return nil, xerrors.Errorf("failed to unmarshal args: %w", err)
}
ret, err := t.Handler(ctx, tb, typedArgs)
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(ret); err != nil {
return json.RawMessage{}, err
}
return buf.Bytes(), err
}, WithCleanContext, WithRecover),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other option would be to attempt to fill out the Arg via reflection, taking a map[string]any as "raw" input. Would definitely be more code, but might be a performance optimization in future for the MCP server which already parses the JSON of the tool invocation.

@johnstcn johnstcn force-pushed the cj/toolsdk-refactor branch from 3a98bbb to 2462f76 Compare April 29, 2025 13:25
Copy link
Member

@kylecarbs kylecarbs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm personally still in favor of the context...

We can still use *Deps as part of the context so not anything can be extracted, but all we're really doing is that, but with a layer of abstraction over all tools.

I don't want to cause Cian more rework so we can merge, but I don't think this accomplishes increased clarity or safety over doing the same thing, but in a context.

I really love how Cian did the args though - that is certainly much improved.

@johnstcn johnstcn merged commit 2acf0ad into main Apr 29, 2025
34 checks passed
@johnstcn johnstcn deleted the cj/toolsdk-refactor branch April 29, 2025 15:05
@github-actions github-actions bot locked and limited conversation to collaborators Apr 29, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants