feat: give the ai chat hub script descriptions and integration metadata

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-11 14:59:08 +02:00
parent c371c62d66
commit 7e4fb283fa
6 changed files with 388 additions and 10 deletions
@@ -78,6 +78,8 @@ pub struct HubScriptResult {
id: i64,
version_id: i64,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
app: String,
kind: String,
score: f32,
@@ -163,6 +165,9 @@ struct HubScript {
id: i64,
version_id: i64,
summary: String,
// Nearly a fifth of hub scripts carry an explicit `"description": null`; a bare
// String here fails the whole blob and takes hub search down with it.
description: Option<String>,
app: String,
kind: String,
embedding: Vec<f32>,
@@ -360,6 +365,10 @@ impl EmbeddingsDb {
let mut hm = HashMap::new();
hm.insert("ask_id".to_string(), script.ask_id.clone().to_string());
hm.insert("summary".to_string(), script.summary.clone());
hm.insert(
"description".to_string(),
script.description.clone().unwrap_or_default(),
);
hm.insert("app".to_string(), script.app.clone());
hm.insert("kind".to_string(), script.kind.clone());
hm.insert("id".to_string(), script.id.clone().to_string());
@@ -517,6 +526,10 @@ impl EmbeddingsDb {
.get("summary")
.ok_or(Error::msg("no summary"))?
.to_owned(),
description: metadata
.get("description")
.filter(|d| !d.is_empty())
.map(|d| d.to_owned()),
app: metadata.get("app").ok_or(Error::msg("no app"))?.to_owned(),
kind: metadata
.get("kind")
+156
View File
@@ -8354,6 +8354,160 @@ paths:
required:
- name
/integrations/hub/{app}/meta:
get:
summary: get hub integration metadata
operationId: getHubIntegrationMeta
tags:
- integration
parameters:
- name: app
description: integration slug
in: path
required: true
schema:
type: string
responses:
"200":
description: integration metadata
content:
application/json:
schema:
type: object
properties:
app:
type: string
display_name:
type: string
description:
type: string
nullable: true
docs_url:
type: string
nullable: true
curated:
description: >-
the script set was pruned to idiomatic actions rather than
generated one per endpoint
type: boolean
metadata_source:
description: >-
whether the provider knowledge was authored (curated) or
inferred from the shipped scripts (derived)
type: string
enum:
- curated
- derived
meta:
description: >-
the integration's authored meta.json verbatim; the content
repo owns its schema, so it is passed through unvalidated
type: object
nullable: true
additionalProperties: true
meta_updated_at:
type: string
nullable: true
derived:
description: facts computed from the integration's shipped scripts
type: object
properties:
api_hosts:
type: array
items:
type: object
properties:
host:
type: string
count:
type: number
required:
- host
- count
style:
type: string
enum:
- fetch
- sdk
- mixed
- unknown
languages:
type: object
additionalProperties:
type: number
script_counts:
type: object
properties:
total:
type: number
by_kind:
type: object
additionalProperties:
type: number
required:
- total
- by_kind
top_scripts:
type: array
items:
type: object
properties:
path:
type: string
ask_id:
type: number
version_id:
type: number
summary:
type: string
description:
type: string
nullable: true
kind:
type: string
language:
type: string
nullable: true
views:
type: number
votes:
type: number
required:
- path
- summary
- kind
required:
- api_hosts
- style
- languages
- script_counts
- top_scripts
resource_types:
type: array
items:
type: object
properties:
id:
type: number
name:
type: string
description:
type: string
nullable: true
schema:
type: object
additionalProperties: true
required:
- name
- schema
required:
- app
- display_name
- curated
- metadata_source
- derived
- resource_types
/flows/hub/list:
get:
summary: list all hub flows
@@ -8731,6 +8885,8 @@ paths:
type: number
summary:
type: string
description:
type: string
app:
type: string
kind:
+27 -2
View File
@@ -1,9 +1,16 @@
use crate::{db::DB, HTTP_CLIENT};
use axum::{extract::Query, response::IntoResponse, routing::get, Extension, Router};
use axum::{
extract::{Path, Query},
response::IntoResponse,
routing::get,
Extension, Router,
};
use windmill_common::{error::Error, utils::query_elems_from_hub, HUB_BASE_URL};
pub fn global_service() -> Router {
Router::new().route("/hub/list", get(list_hub_integrations))
Router::new()
.route("/hub/list", get(list_hub_integrations))
.route("/hub/{app}/meta", get(get_hub_integration_meta))
}
#[derive(serde::Deserialize)]
@@ -29,3 +36,21 @@ async fn list_hub_integrations(
.await?;
Ok::<_, Error>((status_code, headers, response))
}
/// Everything a caller needs to write code against one integration: its resource
/// types, the provider knowledge the content repo authored, and facts derived from
/// the shipped scripts. A hub older than the endpoint answers 404, which passes
/// through as-is.
async fn get_hub_integration_meta(
Path(app): Path<String>,
Extension(db): Extension<DB>,
) -> impl IntoResponse {
let (status_code, headers, response) = query_elems_from_hub(
&HTTP_CLIENT,
&format!("{}/integrations/{}/meta", **HUB_BASE_URL.load(), app),
None,
&db,
)
.await?;
Ok::<_, Error>((status_code, headers, response))
}
@@ -94,6 +94,7 @@ import {
createToolDef,
droppedOptionKeys,
createSearchHubScriptsTool,
getHubIntegrationTool,
executeFlowStepTestRun,
executeTestRun,
findAndReplace,
@@ -1206,7 +1207,8 @@ Rules:
- Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable".
- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.
- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.
- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (record the source hub path in the draft's description), or treat it as a worked example of that integration — which SDK or endpoint it calls, how it authenticates, which resource type it takes — and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing.${webSearchBullet}
- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (record the source hub path in the draft's description), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing.
- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, the auth, pagination, enums, error codes and known gotchas in one call, which beats inferring them from script bodies.${webSearchBullet}
- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).
- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.
${pipelineBullet}
@@ -2625,6 +2627,7 @@ export const globalTools: Tool<{}>[] = [
}
},
createSearchHubScriptsTool(false),
getHubIntegrationTool,
searchNpmPackagesTool,
searchDocsTool,
readDocsPageTool,
@@ -1406,8 +1406,8 @@ describe('createSearchHubScriptsTool', () => {
})
// Browsing by app is what surfaces an integration's other scripts as examples,
// and only the top-scripts endpoint carries the descriptions that make them
// judgeable — the semantic one would both omit those and filter near-misses out.
// and only the top-scripts endpoint takes no query and applies no similarity
// floor, so the near-misses worth reading survive instead of being cut.
it('lists an integration through the top-scripts endpoint, with descriptions', async () => {
const { ScriptService } = await import('$lib/gen')
const getTopHubScripts = vi.fn(async () => ({
@@ -1454,3 +1454,99 @@ describe('createSearchHubScriptsTool', () => {
expect(JSON.parse(raw)).toEqual({ results: [], suggested_integrations: ['stripe'] })
})
})
describe('getHubIntegrationTool', () => {
const doc = {
app: 'confluence',
display_name: 'Confluence',
curated: true,
metadata_source: 'curated',
meta: { gotchas: ['Auth is Basic with an API token, not the password'] },
derived: {
api_hosts: [{ host: 'api.atlassian.com', count: 12 }],
style: 'fetch',
languages: { bun: 14 },
script_counts: { total: 14, by_kind: { script: 13 } },
top_scripts: [
{ path: 'hub/1/confluence/create_page', summary: 'Create page', language: 'bun' }
]
},
resource_types: [{ name: 'confluence', schema: { type: 'object' } }]
}
// Hand-validated provider knowledge and facts inferred from script bodies must
// stay under separate keys, or the model will report guesses as verified.
it('keeps authored notes apart from what was inferred from the scripts', async () => {
const { IntegrationService } = await import('$lib/gen')
Object.assign(IntegrationService, { getHubIntegrationMeta: vi.fn(async () => doc) })
const { getHubIntegrationTool } = await import('./shared')
const parsed = JSON.parse(
await getHubIntegrationTool.fn({
args: { integration: 'confluence' },
toolId: 't1',
toolCallbacks: { setToolStatus: vi.fn() }
} as any)
)
expect(parsed.verified_provider_notes).toEqual(doc.meta)
expect(parsed.observed_from_scripts).toEqual({
api_hosts: ['api.atlassian.com'],
style: 'fetch',
languages: ['bun'],
script_counts: { total: 14, by_kind: { script: 13 } }
})
})
// The hub repo owns this payload, so a hub older than this client can send a
// subset. Reading it must degrade to less content, never to a tool error.
it('returns what an older hub sent instead of failing on the missing sections', async () => {
const { IntegrationService } = await import('$lib/gen')
Object.assign(IntegrationService, {
getHubIntegrationMeta: vi.fn(async () => ({
app: 'stripe',
display_name: 'Stripe',
curated: false
}))
})
const { getHubIntegrationTool } = await import('./shared')
const parsed = JSON.parse(
await getHubIntegrationTool.fn({
args: { integration: 'stripe' },
toolId: 't1',
toolCallbacks: { setToolStatus: vi.fn() }
} as any)
)
expect(parsed.integration).toBe('stripe')
expect(parsed.observed_from_scripts).toBeUndefined()
expect(parsed.resource_types).toEqual([])
expect(parsed.example_scripts).toEqual([])
})
// A hub with no such integration and one too old to serve the endpoint both 404;
// neither may surface as a tool error, since the model can still read scripts.
it('suggests real slugs instead of failing when the integration is unknown', async () => {
const { IntegrationService } = await import('$lib/gen')
Object.assign(IntegrationService, {
getHubIntegrationMeta: vi.fn(async () => {
throw new Error('Not Found')
}),
listHubIntegrations: vi.fn(async () => [{ name: 'stripe' }, { name: 'slack' }])
})
const { getHubIntegrationTool, clearHubIntegrationsCache } = await import('./shared')
clearHubIntegrationsCache()
const parsed = JSON.parse(
await getHubIntegrationTool.fn({
args: { integration: 'stripe_billing' },
toolId: 't1',
toolCallbacks: { setToolStatus: vi.fn() }
} as any)
)
expect(parsed.error).toContain('stripe_billing')
expect(parsed.suggested_integrations).toEqual(['stripe'])
})
})
@@ -1247,8 +1247,8 @@ export function isHubPath(path: string): boolean {
const MAX_BROWSED_HUB_SCRIPTS = 20
const MAX_SUGGESTED_INTEGRATIONS = 5
/** Common shape of the two hub listings. Only the top-scripts one carries a
* description; the semantic search response has no field for it. */
/** Common shape of the two hub listings. Both carry a description, but the hub
* has none for roughly a fifth of its scripts. */
type HubScriptHit = { version_id: number; app: string; summary: string; description?: string }
/** The integration slugs are a large but static list, so one fetch per session
@@ -1284,6 +1284,91 @@ export const clearHubIntegrationsCache = () => {
hubIntegrationsCache = undefined
}
const getHubIntegrationSchema = z.object({
integration: z
.string()
.describe(
'Integration slug, e.g. "stripe". Take it from a search_hub_scripts result\'s `integration`, or guess the vendor name: a wrong guess comes back with the closest real slugs.'
)
})
const getHubIntegrationToolDef = createToolDef(
getHubIntegrationSchema,
'get_hub_integration',
'Read how one integration works before writing code against it: which resource type it takes, its auth, pagination, enums, error codes and gotchas, plus its most-used scripts as examples.'
)
/** Enough to show the integration's idiom; search_hub_scripts is the way to find
* a specific one. */
const MAX_INTEGRATION_EXAMPLES = 5
export const getHubIntegrationTool = {
def: getHubIntegrationToolDef,
fn: async ({ args, toolId, toolCallbacks }) => {
const { integration } = getHubIntegrationSchema.parse(args)
toolCallbacks.setToolStatus(toolId, { content: `Reading the ${integration} integration...` })
let doc: Awaited<ReturnType<typeof IntegrationService.getHubIntegrationMeta>>
try {
doc = await IntegrationService.getHubIntegrationMeta({ app: integration })
} catch {
// An unknown slug and a hub predating the endpoint both answer 404, and the
// response is the same either way: hand back real slugs so the model can
// retry or fall back to reading scripts.
toolCallbacks.setToolStatus(toolId, { content: `No hub integration named ${integration}` })
const suggested = await suggestHubIntegrations(integration)
return JSON.stringify({
error: `No hub metadata for "${integration}".`,
suggested_integrations: suggested
})
}
toolCallbacks.setToolStatus(toolId, { content: `Read the ${doc.display_name} integration` })
// The hub owns this response's shape and can be older than this client, so
// every section is read as optional: a hub that sends less should return less,
// not fail the call.
const derived = doc.derived
return JSON.stringify({
integration: doc.app,
display_name: doc.display_name,
...(doc.description ? { description: doc.description } : {}),
...(doc.docs_url ? { docs_url: doc.docs_url } : {}),
// Authored provider knowledge and facts inferred from the scripts stay
// separate: only the former was checked against the live API.
...(doc.meta ? { verified_provider_notes: doc.meta } : {}),
...(derived
? {
observed_from_scripts: {
api_hosts: derived.api_hosts?.map((h) => h.host) ?? [],
style: derived.style,
languages: Object.keys(derived.languages ?? {}),
script_counts: derived.script_counts
}
}
: {}),
// Said in the payload rather than the system prompt: it is only true of some
// integrations, and only matters once the model has asked about one.
...(doc.curated
? {}
: {
scripts_note:
'These scripts were generated one per API endpoint from a spec: good for the endpoint shapes, weak as style examples.'
}),
resource_types: (doc.resource_types ?? []).map((rt) => ({
name: rt.name,
...(rt.description ? { description: rt.description } : {}),
schema: rt.schema
})),
example_scripts: (derived?.top_scripts ?? []).slice(0, MAX_INTEGRATION_EXAMPLES).map((s) => ({
path: s.path,
summary: s.summary,
...(s.description ? { description: s.description } : {}),
...(s.language ? { language: s.language } : {})
}))
})
}
} satisfies Tool<{}>
export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
def: searchHubScriptsToolDef,
fn: async ({ args, toolId, toolCallbacks }) => {
@@ -1298,9 +1383,9 @@ export const createSearchHubScriptsTool = (withContent: boolean = false) => ({
toolCallbacks.setToolStatus(toolId, { content: `Searching hub scripts for ${subject}...` })
// Listing an integration goes through the hub's top-scripts endpoint rather
// than the semantic one: it is the only one carrying each script's
// description, and it has no similarity floor to drop the near-misses that
// are worth reading as examples of how the integration is used.
// than the semantic one: it takes no query, and it applies no similarity
// floor, so the near-misses worth reading as examples of how the integration
// is used survive instead of being cut.
const scripts: HubScriptHit[] = query
? await ScriptService.queryHubScripts({ text: query, kind: 'script', app })
: ((