package skill import ( "encoding/json" "fmt" "sort" "strings" "gopkg.in/yaml.v3" ) // CandidateTool is the intermediate representation produced by an importer // (OpenAPI, manual, ...) before the LLM synthesis pass refines it into a // finished Tool. Importers aim to fill as many fields as the source provides. type CandidateTool struct { Name string `json:"name"` Description string `json:"description"` Parameters jsonObject `json:"parameters"` // JSON Schema Endpoint Endpoint `json:"endpoint"` } // ParseOpenAPI accepts JSON or YAML OpenAPI 3.x (and best-effort Swagger 2.0) // text and returns one CandidateTool per operation under paths. This is a // deterministic, no-LLM pass: it just translates the spec into our endpoint // shape. The synthesis pass later decides which candidates to keep and how to // phrase descriptions. func ParseOpenAPI(raw []byte, baseURL string) ([]CandidateTool, error) { spec, err := unmarshalSpec(raw) if err != nil { return nil, err } if baseURL == "" { baseURL = spec.baseURL() } var out []CandidateTool // Stable iteration order so generated skills are deterministic across runs. paths := make([]string, 0, len(spec.Paths)) for p := range spec.Paths { paths = append(paths, p) } sort.Strings(paths) for _, p := range paths { item := spec.Paths[p] for _, method := range []string{"get", "post", "put", "patch", "delete"} { op := item.operation(method) if op == nil { continue } ct, err := buildCandidate(method, p, op, baseURL) if err != nil { // Skip a malformed operation rather than failing the whole spec. continue } out = append(out, ct) } } return out, nil } // --- intermediate openapi structures --------------------------------------- // // Only the fields we actually consume are modelled. Anything else is ignored. // Both JSON and YAML unmarshal into these via the json tags (yaml.v3 reads the // same lower-cased keys). type oaSpec struct { OpenAPI string `json:"openapi"` Info oaInfo `json:"info"` Servers []oaServer `json:"servers"` Host string `json:"host"` // swagger 2.0 BasePath string `json:"basePath"` // swagger 2.0 Paths map[string]*oaPathItem `json:"paths"` } type oaInfo struct { Title string `json:"title"` Description string `json:"description"` } type oaServer struct { URL string `json:"url"` } type oaPathItem struct { Get *oaOperation `json:"get"` Post *oaOperation `json:"post"` Put *oaOperation `json:"put"` Patch *oaOperation `json:"patch"` Delete *oaOperation `json:"delete"` } type oaOperation struct { Summary string `json:"summary"` Description string `json:"description"` OperationID string `json:"operationId"` Parameters []oaParameter `json:"parameters"` } type oaParameter struct { Name string `json:"name"` In string `json:"in"` // query | path | header Required bool `json:"required"` Schema *oaSchemaRef `json:"schema"` Description string `json:"description"` } type oaSchemaRef struct { Type string `json:"type"` } func (s *oaSpec) baseURL() string { if len(s.Servers) > 0 && s.Servers[0].URL != "" { return strings.TrimRight(s.Servers[0].URL, "/") } if s.Host != "" { bp := strings.Trim(s.BasePath, "/") scheme := "https" if strings.HasPrefix(s.Host, "localhost") || strings.HasPrefix(s.Host, "127.") { scheme = "http" } if bp != "" { return fmt.Sprintf("%s://%s/%s", scheme, s.Host, bp) } return fmt.Sprintf("%s://%s", scheme, s.Host) } return "" } func (p *oaPathItem) operation(method string) *oaOperation { switch method { case "get": return p.Get case "post": return p.Post case "put": return p.Put case "patch": return p.Patch case "delete": return p.Delete } return nil } // buildCandidate converts one OpenAPI operation into a CandidateTool. func buildCandidate(method, p string, op *oaOperation, baseURL string) (CandidateTool, error) { name := op.OperationID if name == "" { // operationId absent: synthesise from method + path. name = method + strings.ReplaceAll(strings.ReplaceAll(p, "/", "_"), "{", "") name = strings.ReplaceAll(name, "}", "") name = strings.Trim(name, "_") } if name == "" { return CandidateTool{}, fmt.Errorf("cannot derive name") } desc := strings.TrimSpace(op.Description) if desc == "" { desc = strings.TrimSpace(op.Summary) } if desc == "" { desc = name } // Split parameters into query / path / header and build a flat JSON Schema // of the arguments the LLM needs to supply. schemaProps := map[string]any{} var required []string var query, hdr map[string]string pathParams := map[string]string{} for _, param := range op.Parameters { if param.Name == "" { continue } t := "string" if param.Schema != nil && param.Schema.Type != "" { t = param.Schema.Type } entry := map[string]any{"type": t} if param.Description != "" { entry["description"] = param.Description } schemaProps[param.Name] = entry if param.Required { required = append(required, param.Name) } switch param.In { case "query": if query == nil { query = map[string]string{} } query[param.Name] = "{{" + param.Name + "}}" case "path": pathParams[param.Name] = "{{" + param.Name + "}}" case "header": if hdr == nil { hdr = map[string]string{} } hdr[param.Name] = "{{" + param.Name + "}}" } } schema := map[string]any{ "type": "object", "properties": schemaProps, } if len(required) > 0 { sort.Strings(required) schema["required"] = required } paramsJSON, _ := json.Marshal(schema) // Render the full URL: combine baseURL + path. Path params stay as // {name}; the agent executor substitutes them at call time. We avoid // url.Parse here because it would percent-encode the braces. fullURL := joinURL(baseURL, p) return CandidateTool{ Name: name, Description: desc, Parameters: jsonObject(paramsJSON), Endpoint: Endpoint{ Method: strings.ToUpper(method), URL: fullURL, Path: pathParams, Query: query, Header: hdr, }, }, nil } // joinURL merges a base URL with an OpenAPI path. We deliberately do NOT use // net/url for the join: it would percent-encode the "{name}" path-parameter // placeholders into "%7Bname%7D", which the executor's literal replace would // then fail to match. String concatenation keeps the braces intact. func joinURL(base, p string) string { base = strings.TrimRight(strings.TrimSpace(base), "/") p = strings.TrimSpace(p) if base == "" { return p } if p == "" { return base } if !strings.HasPrefix(p, "/") { p = "/" + p } return base + p } // unmarshalSpec accepts either JSON or YAML (which is a JSON superset) by // routing through yaml.v3, then re-marshals to JSON so the typed oaSpec can // decode cleanly with consistent key casing. func unmarshalSpec(raw []byte) (*oaSpec, error) { var generic map[string]any if err := yaml.Unmarshal(raw, &generic); err != nil { return nil, fmt.Errorf("parse spec (yaml/json): %w", err) } // OpenAPI paths keys can be "/foo": {...}; keep them as-is. jb, err := json.Marshal(generic) if err != nil { return nil, fmt.Errorf("re-marshal spec: %w", err) } var spec oaSpec if err := json.Unmarshal(jb, &spec); err != nil { return nil, fmt.Errorf("decode spec: %w", err) } if spec.Paths == nil { return nil, fmt.Errorf("spec has no paths") } return &spec, nil }