diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a9feafcb8..9240fb50ba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -40,11 +40,12 @@ address. ## Agents and workflows -Myra (`agents/myra`) is the only agent Workbench ships. There is no -workflow catalog to browse — Myra creates a workflow, tool, or skill -dynamically, as code, when a job needs one, and deploys it through the -stock workflow-deploy route. `tools/*` and `skills/*` are ordinary -`@corbits/*` packages an agent can pin. +Myra (`agents/myra`) is the only agent Workbench ships. Her tools are +Interchange's own tool packages — `@intx/tools-mail` over her mail +transport and `@intx/tools-posix` over her working tree. The platform has +no notion of an agent calling the hub API, so Workbench ships no +hub-calling tool package: Myra writes a workflow or agent as code and the +person deploys it from Workbench through the stock workflow-deploy route. ## Data diff --git a/README.md b/README.md index fa701c0fcb..a8f0b46a24 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ local [Ollama](https://ollama.com) works with no key, see | ------------- | ---------------------------------------------------------------- | | `apps/` | `hub` (API), `web` (React client), `sidecar` (execution host) | | `packages/` | Corbits libraries | -| `tools/` | `@corbits/*` agent tool packages | | `agents/` | `@corbits/myra`, the one shipped agent | | `vendor/intx` | Hand-copied Interchange packages, see [VENDORED.md](VENDORED.md) | @@ -48,5 +47,5 @@ Conventions live in [AGENTS.md](AGENTS.md). ## License The application is GPLv2 with the [AI Exception](GPLv2-AI-Exception.md); -see [LICENSE.md](LICENSE.md). Libraries under `packages/`, `tools/`, and -`agents/` are LGPL-2.1. +see [LICENSE.md](LICENSE.md). Libraries under `packages/` and `agents/` +are LGPL-2.1. diff --git a/agents/myra/package.json b/agents/myra/package.json index 12ed4c019c..8605368cba 100644 --- a/agents/myra/package.json +++ b/agents/myra/package.json @@ -20,7 +20,6 @@ "exports": { ".": "./src/index.ts", "./prompt": "./src/system-prompt.ts", - "./tool-packages": "./src/tool-packages.ts", "./workflow-ids": "./src/workflow-ids.ts", "./bundle": "./src/bundle.ts" }, @@ -33,14 +32,8 @@ "build": "bun run scripts/build-bundle.ts" }, "dependencies": { - "@corbits/access-tools": "workspace:*", - "@corbits/agent-directory-tools": "workspace:*", - "@corbits/capability-tools": "workspace:*", - "@corbits/catalog-tools": "workspace:*", - "@corbits/interaction-tools": "workspace:*", - "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", - "@corbits/skills-tools": "workspace:*", - "@corbits/workflow-authoring-tools": "workspace:*" + "@intx/tools-mail": "0.3.0", + "@intx/tools-posix": "0.3.0" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/agents/myra/src/index.ts b/agents/myra/src/index.ts index b6dae64eb9..34d8fa61b4 100644 --- a/agents/myra/src/index.ts +++ b/agents/myra/src/index.ts @@ -11,19 +11,12 @@ import type { AgentDefinition, AnnotatedToolFactory, InferencePreference } from "@intx/agent"; import { defineWorkflow, step } from "@intx/workflow"; import type { WorkflowDefinition } from "@intx/workflow"; -import { memoryAdd, memoryList, memorySearch } from "@corbits/memory/tools"; -import { accessTools } from "@corbits/access-tools"; -import { agentDirectoryTools } from "@corbits/agent-directory-tools"; -import { capabilityTools } from "@corbits/capability-tools"; -import { catalogTools } from "@corbits/catalog-tools"; -import { interactionTools } from "@corbits/interaction-tools"; -import { skillsManageTools, skillsQueryTools } from "@corbits/skills-tools"; -import { workflowAuthoringTools } from "@corbits/workflow-authoring-tools"; +import { mail } from "@intx/tools-mail/sidecar-bundle"; +import { posix } from "@intx/tools-posix/sidecar-bundle"; import { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "./workflow-ids"; export { ASSISTANT_SYSTEM_PROMPT } from "./system-prompt"; -export { ASSISTANT_TOOL_PACKAGE_PINS } from "./tool-packages"; export { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID } from "./workflow-ids"; /** The description the agent step carries; mirrored by the JSON projection @@ -32,21 +25,10 @@ export const ASSISTANT_DESCRIPTION = "A general-purpose assistant that answers questions, drafts " + "text, and reasons through problems for the team"; -// Each factory declares its own env requirements, so the union has no -// common `EnvReq`; the runtime `validateEnv` is the load-bearing check. -export const MYRA_TOOL_FACTORIES = [ - memoryAdd, - memorySearch, - memoryList, - capabilityTools, - agentDirectoryTools, - catalogTools, - skillsQueryTools, - skillsManageTools, - interactionTools, - workflowAuthoringTools, - accessTools, -] as unknown as readonly AnnotatedToolFactory[]; +// Upstream's own tool packages, the only kind of tool Interchange has: +// mail over the agent's transport and posix over its working tree. Neither +// calls a hub API — nothing in the platform gives an agent that reach. +export const MYRA_TOOL_FACTORIES = [mail, posix] as unknown as readonly AnnotatedToolFactory[]; /** Everything the definition needs that is per-deployment data. */ export interface MyraWorkflowInput { diff --git a/agents/myra/src/system-prompt.ts b/agents/myra/src/system-prompt.ts index 8689e8f366..d259713cfa 100644 --- a/agents/myra/src/system-prompt.ts +++ b/agents/myra/src/system-prompt.ts @@ -16,8 +16,12 @@ export const ASSISTANT_SYSTEM_PROMPT = "\n" + "Act once you have what you need for anything read-only or " + "reversible; ask first before anything with an external or " + - "hard-to-undo effect (creating a teammate, sending mail on someone " + - "else's behalf, connecting a service). Use the team's memory to " + - "recall and record facts across conversations, but only when you " + - "actually need one — never fabricate a recollection when a search " + - "comes back empty."; + "hard-to-undo effect, such as sending mail on someone else's " + + "behalf.\n" + + "\n" + + "You have mail and a working tree, and nothing else reaches the " + + "workbench itself. To stand up a new agent or workflow, write it as " + + "a package in your working tree and ask the person to deploy it " + + "from Workbench. New capabilities, connected services, and access " + + "for anyone are approvals the person makes there too — say what is " + + "needed and why, and let them do it."; diff --git a/agents/myra/src/tool-packages.ts b/agents/myra/src/tool-packages.ts deleted file mode 100644 index 908e11e0d1..0000000000 --- a/agents/myra/src/tool-packages.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ToolPackagePin } from "@intx/types/tool-packages"; - -// No `@intx/tools-posix`: Myra never gets raw filesystem access. -export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ - { name: "@corbits/memory", version: "0.1.2" }, - { name: "@corbits/capability-tools", version: "0.0.7" }, - { name: "@corbits/agent-directory-tools", version: "0.0.7" }, - { name: "@corbits/catalog-tools", version: "0.1.1" }, - { name: "@corbits/skills-tools", version: "0.0.9" }, - { name: "@corbits/interaction-tools", version: "0.0.11" }, - { name: "@corbits/workflow-authoring-tools", version: "0.0.8" }, - { name: "@corbits/access-tools", version: "0.0.6" }, -]; diff --git a/apps/hub/package.json b/apps/hub/package.json index b4e5431e29..563da3b4dc 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -18,7 +18,6 @@ "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider#a1f475f341261c05fa8dade0c82f1246c175b5b3", "@corbits/cron": "workspace:*", "@corbits/error-sink": "workspace:*", - "@corbits/inference-catalog": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", "@corbits/url-path": "workspace:*", diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 24a6faef79..112bd72485 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -26,7 +26,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ensurePrimaryTenant, runPortableClientBootstrap } from "../client-bootstrap"; import { createFetchStockHub, findOwnedTenants } from "../needs-converge"; import { deployMyraSource } from "../myra-deploy"; -import { publishToolPackageRegistry } from "../tools/registry-publish"; import { useNavigate } from "../navigation"; import { triggerFirstLoginProvisioning } from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; @@ -154,28 +153,21 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { tenantDomain: string, offering: ExistingOffering, ): Promise { - // Myra's pins resolve from the tenant registry, so the tool packages - // must be there before her definition deploys. - return publishToolPackageRegistry(tenantId) - .then(() => - deployMyraSource({ - tenantId, - tenantDomain, - sourceOfferingIds: offering.sourceOfferingIds, - defaultSourceOfferingId: offering.defaultSourceOfferingId, - declaredSources: offering.declaredSources, - }), - ) - .then( - (myraDeploy) => setState({ phase: "installing", myraDeploy }), - (error: unknown) => { - setState({ - phase: "error", - message: - error instanceof Error ? error.message : "Publishing Myra's source hit a snag.", - }); - }, - ); + return deployMyraSource({ + tenantId, + tenantDomain, + sourceOfferingIds: offering.sourceOfferingIds, + defaultSourceOfferingId: offering.defaultSourceOfferingId, + declaredSources: offering.declaredSources, + }).then( + (myraDeploy) => setState({ phase: "installing", myraDeploy }), + (error: unknown) => { + setState({ + phase: "error", + message: error instanceof Error ? error.message : "Publishing Myra's source hit a snag.", + }); + }, + ); } // Step 4: the installer itself, run once a `myraDeploy` is in hand. A diff --git a/apps/web/src/pages/tools-page.tsx b/apps/web/src/pages/tools-page.tsx index 8692deabd8..b21b38981d 100644 --- a/apps/web/src/pages/tools-page.tsx +++ b/apps/web/src/pages/tools-page.tsx @@ -1,7 +1,6 @@ // Tools: a standalone rail destination listing the tool packages this -// tenant has published into its own registry — the same -// `corbits-tools` package-registry asset `registry-publish.ts` writes to. -// No stock route lists a tenant's MCP servers yet, so this page has +// tenant has published into its own `corbits-tools` package-registry +// asset. No stock route lists a tenant's MCP servers yet, so this page has // nothing to show for those until one exists. import { diff --git a/apps/web/src/tools/registry-publish.test.ts b/apps/web/src/tools/registry-publish.test.ts deleted file mode 100644 index 86075bf8fc..0000000000 --- a/apps/web/src/tools/registry-publish.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Every OpenAI-compatible provider (OpenAI, xAI, Groq, DeepSeek, Mistral, -// OpenRouter, Ollama, ...) caps a function name at 64 chars on the wire, -// and the wire form of a qualified tool name (`:`) grows -// under encoding. A bundle whose longest tool blows the cap fails the -// agent's first turn on every such provider — so every corbits tool -// package's names are checked here against the strictest limit. -import { describe, expect, test } from "bun:test"; -import path from "node:path"; -import { existsSync } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { encodeToolName } from "@intx/inference"; - -const OPENAI_LIMIT = { provider: "openai", maxLength: 64 } as const; - -type Bundle = { - readonly id: string; - readonly definitions: readonly { readonly name: string }[]; -}; - -function isBundle(value: unknown): value is Bundle { - return ( - typeof value === "function" && - typeof (value as { id?: unknown }).id === "string" && - Array.isArray((value as { definitions?: unknown }).definitions) - ); -} - -const TOOLS_ROOT = path.resolve(import.meta.dir, "../../../../tools"); - -async function toolPackageDirs(): Promise { - const entries = await readdir(TOOLS_ROOT, { withFileTypes: true }); - return entries - .filter( - (entry) => - entry.isDirectory() && existsSync(path.join(TOOLS_ROOT, entry.name, "package.json")), - ) - .map((entry) => entry.name); -} - -describe("corbits tool packages fit the 64-char OpenAI-compatible tool-name cap", () => { - test("every tools/* package's qualified tool names encode within the cap", async () => { - const dirs = await toolPackageDirs(); - expect(dirs.length).toBeGreaterThan(0); - for (const dir of dirs) { - const mod = (await import(path.join(TOOLS_ROOT, dir, "src", "index.ts"))) as Record< - string, - unknown - >; - const bundles = Object.values(mod).filter(isBundle); - expect(bundles.length).toBeGreaterThan(0); - for (const bundle of bundles) { - for (const definition of bundle.definitions) { - const qualified = `${bundle.id}:${definition.name}`; - expect(() => encodeToolName(qualified, OPENAI_LIMIT)).not.toThrow(); - } - } - } - }); -}); diff --git a/apps/web/src/tools/registry-publish.ts b/apps/web/src/tools/registry-publish.ts deleted file mode 100644 index bb8a3635d5..0000000000 --- a/apps/web/src/tools/registry-publish.ts +++ /dev/null @@ -1,218 +0,0 @@ -// Publishes every `tools/*` package's raw TypeScript source as an -// npm-shaped tarball into the tenant's `corbits-tools` package-registry -// asset. Interchange's stock tool-package resolver installs and runs a -// tool package straight from its packed source, so no bundling step is -// needed. `tools/*` directories are read off the workspace at build time -// via `import.meta.glob`, so a new package is picked up automatically. -import { type } from "arktype"; - -import { packTarball } from "./tarball"; - -export const CORBITS_TOOLS_REGISTRY = "corbits-tools"; - -class RegistryPublishError extends Error {} - -async function readErrorBody(response: Response): Promise { - const body: unknown = await response.json().catch(() => undefined); - const envelope = type({ - error: { code: "string", userMessage: "string", refId: "string" }, - })(body); - return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; -} - -const AssetCreatedShape = type({ id: "string" }); -const AssetListShape = type({ id: "string", name: "string" }).array(); -const TarballPutShape = type({ commit: "string", integrity: "string" }); - -/** Idempotently ensures the tenant's `corbits-tools` package-registry - * asset exists, mirroring `myra-deploy.ts`'s `ensureMyraSourceAsset`. */ -export async function ensureCorbitsToolsRegistryAsset( - tenantId: string, - fetchImpl: typeof fetch = fetch, -): Promise { - const created = await fetchImpl(`/api/tenants/${encodeURIComponent(tenantId)}/assets`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "package-registry", name: CORBITS_TOOLS_REGISTRY }), - }); - if (created.status === 201) { - const parsed = AssetCreatedShape(await created.json()); - if (parsed instanceof type.errors) { - throw new RegistryPublishError( - `the ${CORBITS_TOOLS_REGISTRY} registry came back an unexpected shape: ${parsed.summary}`, - ); - } - return parsed.id; - } - if (created.status !== 409) { - throw new RegistryPublishError( - `preparing the ${CORBITS_TOOLS_REGISTRY} registry failed: ${await readErrorBody(created)}`, - ); - } - const listed = await fetchImpl( - `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=package-registry&inherited=false`, - ); - if (!listed.ok) { - throw new RegistryPublishError( - `checking the ${CORBITS_TOOLS_REGISTRY} registry failed: ${await readErrorBody(listed)}`, - ); - } - const parsed = AssetListShape(await listed.json()); - if (parsed instanceof type.errors) { - throw new RegistryPublishError( - `the ${CORBITS_TOOLS_REGISTRY} registry's asset list came back an unexpected shape: ${parsed.summary}`, - ); - } - const existing = parsed.find((asset) => asset.name === CORBITS_TOOLS_REGISTRY); - if (existing === undefined) { - throw new RegistryPublishError( - `the ${CORBITS_TOOLS_REGISTRY} registry reported a name conflict but is not listed on this workbench`, - ); - } - return existing.id; -} - -// Specifiers that only resolve inside this monorepo's own workspace — -// resolved to the concrete version a published tarball's consumer can -// actually install, taken from the root `catalog` this repo pins them at. -const CATALOG_VERSIONS: Record = { - arktype: "^2.2.0", - semver: "^7.7.2", -}; - -function resolvedDependencySpec(packageName: string, spec: string): string { - if (spec === "catalog:") { - const resolved = CATALOG_VERSIONS[packageName]; - if (resolved === undefined) { - throw new RegistryPublishError( - `registry-publish: no catalog version recorded for "${packageName}" — add it to CATALOG_VERSIONS`, - ); - } - return resolved; - } - return spec; -} - -type ToolPackageManifest = { - readonly name: string; - readonly version: string; - readonly exports?: Record; - readonly dependencies?: Record; - readonly peerDependencies?: Record; -}; - -export type ToolPackageTarball = { readonly name: string; readonly version: string }; - -function tarballFilenameFor(name: string, version: string): string { - return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`; -} - -function toolDirName(manifestPath: string): string { - const match = /\/tools\/([^/]+)\/package\.json$/.exec(manifestPath); - const dir = match?.[1]; - if (dir === undefined) { - throw new RegistryPublishError(`registry-publish: unexpected manifest path ${manifestPath}`); - } - return dir; -} - -/** One `tools/*` package's manifest plus every `src/**\/*.ts` file it - * ships, staged the same shape `packTarball` expects. */ -function buildToolPackageTree( - manifestPath: string, - manifestJson: string, - sourceFiles: Record, -): { - name: string; - version: string; - tree: Record; -} { - const manifest = JSON.parse(manifestJson) as ToolPackageManifest; - const dependencies = Object.fromEntries( - Object.entries({ ...manifest.dependencies, ...manifest.peerDependencies }).map( - ([name, spec]) => [name, resolvedDependencySpec(name, spec)], - ), - ); - const dir = toolDirName(manifestPath); - const prefix = `../../../../tools/${dir}/`; - const tree: Record = { - "package.json": JSON.stringify( - { - name: manifest.name, - version: manifest.version, - type: "module", - exports: manifest.exports, - dependencies, - interchange: { tools: manifest.exports?.["."] ?? "./src/index.ts" }, - }, - null, - 2, - ), - }; - for (const [sourcePath, contents] of Object.entries(sourceFiles)) { - if (!sourcePath.startsWith(prefix) || /\.test\.tsx?$/.test(sourcePath)) continue; - tree[sourcePath.slice(prefix.length)] = contents; - } - return { name: manifest.name, version: manifest.version, tree }; -} - -/** Every `tools/*` package this workspace ships, as the `packTarball`-ready - * trees `publishToolPackageRegistry` packs and uploads. Exported standalone - * so the packaging is unit-testable without a fetch round trip. - * - * The globs are read here, not at module scope, so importing this module - * (e.g. transitively, from a bun test) never touches `import.meta.glob` — - * a Vite-only API — unless a caller actually builds the trees. */ -export function buildToolPackageTrees(): { - name: string; - version: string; - tree: Record; -}[] { - const manifests = import.meta.glob("../../../../tools/*/package.json", { - query: "?raw", - import: "default", - eager: true, - }) as Record; - const sourceFiles = import.meta.glob("../../../../tools/*/src/**/*.ts", { - query: "?raw", - import: "default", - eager: true, - }) as Record; - return Object.entries(manifests).map(([manifestPath, manifestJson]) => - buildToolPackageTree(manifestPath, manifestJson, sourceFiles), - ); -} - -/** Packs and publishes every `tools/*` package into the tenant's - * `corbits-tools` registry, ensuring the registry asset first. Returns - * the filenames it published. */ -export async function publishToolPackageRegistry( - tenantId: string, - fetchImpl: typeof fetch = fetch, -): Promise { - const assetId = await ensureCorbitsToolsRegistryAsset(tenantId, fetchImpl); - const published: ToolPackageTarball[] = []; - for (const { name, version, tree } of buildToolPackageTrees()) { - const tarball = await packTarball(tree); - const filename = tarballFilenameFor(name, version); - const response = await fetchImpl( - `/api/tenants/${encodeURIComponent(tenantId)}/assets/${encodeURIComponent(assetId)}/tarballs/${encodeURIComponent(filename)}`, - { - method: "PUT", - headers: { "content-type": "application/octet-stream" }, - body: new Blob([tarball as Uint8Array]), - }, - ); - if (!response.ok) { - throw new RegistryPublishError(`publishing ${name} failed: ${await readErrorBody(response)}`); - } - const parsed = TarballPutShape(await response.json()); - if (parsed instanceof type.errors) { - throw new RegistryPublishError( - `${name}'s tarball upload came back an unexpected shape: ${parsed.summary}`, - ); - } - published.push({ name, version }); - } - return published; -} diff --git a/apps/web/src/tools/registry-read.ts b/apps/web/src/tools/registry-read.ts index 5ff0cfce46..637000feb9 100644 --- a/apps/web/src/tools/registry-read.ts +++ b/apps/web/src/tools/registry-read.ts @@ -1,8 +1,6 @@ -// Reads the tenant's published tool packages back off the stock registry -// surface `registry-publish.ts` writes to: the `corbits-tools` +// Reads the tenant's published tool packages off its `corbits-tools` // package-registry asset's tarball listing. There is no packument route — -// a tarball entry is only ever `-.tgz` (see -// `tarballFilenameFor` in `./registry-publish.ts`) — so name/version are +// a tarball entry is only ever `-.tgz` — so name/version are // recovered from the filename and nothing further (description, declared // tools) is available without unpacking the tarball itself. diff --git a/apps/web/src/tools/tarball.test.ts b/apps/web/src/tools/tarball.test.ts deleted file mode 100644 index b192c1ad3a..0000000000 --- a/apps/web/src/tools/tarball.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { packTarball } from "./tarball"; - -describe("packTarball", () => { - test("writes USTAR headers whose checksum verifies and whose typeflag survives", async () => { - const tarball = await packTarball({ "package.json": "{}" }); - const tar = new Uint8Array( - await new Response( - new Blob([tarball as Uint8Array]) - .stream() - .pipeThrough(new DecompressionStream("gzip")), - ).arrayBuffer(), - ); - const header = tar.slice(0, 512); - let expected = 0; - for (let i = 0; i < 512; i++) expected += i >= 148 && i < 156 ? 0x20 : header[i]!; - const stored = parseInt(new TextDecoder().decode(header.slice(148, 154)), 8); - expect(stored).toBe(expected); - expect(String.fromCharCode(header[156]!)).toBe("0"); - }); -}); diff --git a/apps/web/src/tools/tarball.ts b/apps/web/src/tools/tarball.ts deleted file mode 100644 index 949982cb17..0000000000 --- a/apps/web/src/tools/tarball.ts +++ /dev/null @@ -1,68 +0,0 @@ -// A minimal USTAR + gzip writer: the browser's own `CompressionStream` -// compresses, so only the tar container needs hand-rolling. - -const TAR_BLOCK_SIZE = 512; - -export class TarballError extends Error {} - -function padOctal(value: number, width: number): string { - return value.toString(8).padStart(width - 1, "0") + "\0"; -} - -function tarEntry(path: string, content: Uint8Array): Uint8Array { - if (path.length >= 100) { - throw new TarballError(`tar entry path ${JSON.stringify(path)} is too long for a USTAR header`); - } - const header = new Uint8Array(TAR_BLOCK_SIZE); - const encoder = new TextEncoder(); - const writeField = (offset: number, value: string): void => { - header.set(encoder.encode(value), offset); - }; - writeField(0, path); - writeField(100, padOctal(0o644, 8)); - writeField(108, padOctal(0, 8)); - writeField(116, padOctal(0, 8)); - writeField(124, padOctal(content.length, 12)); - writeField(136, padOctal(0, 12)); - writeField(148, " "); - writeField(156, "0"); - writeField(257, "ustar\0"); - writeField(263, "00"); - - let checksum = 0; - for (const byte of header) checksum += byte; - // The 8-byte checksum field is 6 octal digits, NUL, space; anything - // longer spills into the typeflag byte and fails every reader's check. - writeField(148, checksum.toString(8).padStart(6, "0") + "\0 "); - - const paddedLength = Math.ceil(content.length / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; - const body = new Uint8Array(paddedLength); - body.set(content); - - const entry = new Uint8Array(header.length + body.length); - entry.set(header, 0); - entry.set(body, header.length); - return entry; -} - -/** Packs a `{ path: contents }` tree into an npm-shaped gzipped tar - * archive rooted at `package/`. */ -export async function packTarball(tree: Readonly>): Promise { - const encoder = new TextEncoder(); - const entries: Uint8Array[] = []; - for (const [path, contents] of Object.entries(tree)) { - entries.push(tarEntry(`package/${path}`, encoder.encode(contents))); - } - const endOfArchive = new Uint8Array(TAR_BLOCK_SIZE * 2); - const totalLength = entries.reduce((sum, entry) => sum + entry.length, 0) + endOfArchive.length; - const tar = new Uint8Array(totalLength); - let offset = 0; - for (const entry of entries) { - tar.set(entry, offset); - offset += entry.length; - } - tar.set(endOfArchive, offset); - - const gzipStream = new Blob([tar]).stream().pipeThrough(new CompressionStream("gzip")); - return new Uint8Array(await new Response(gzipStream).arrayBuffer()); -} diff --git a/apps/web/src/workbench-create.ts b/apps/web/src/workbench-create.ts index 5b9efedd28..b06cc12816 100644 --- a/apps/web/src/workbench-create.ts +++ b/apps/web/src/workbench-create.ts @@ -10,7 +10,6 @@ import { readAgentSource } from "./agent-source-read"; import { deployMyraSource } from "./myra-deploy"; import { createFetchStockHub } from "./needs-converge"; import { resolveExistingOffering } from "./onboarding/provider-connect-step"; -import { publishToolPackageRegistry } from "./tools/registry-publish"; export class WorkbenchCreateError extends Error { constructor( @@ -69,9 +68,6 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-codex-provider-a1f475f", "sha512-D6d0lblWx/4KudfLwQoQGrLB8GKRLP/HVp1cx08L2/hOGZAgH6txLPpeEtBqjl+R520qXSSPfM0kDC+3XNaAfw=="], "@corbits/credential-header": ["@corbits/credential-header@github:corbitsdev/corbits-credential-header#eee7b7e", { "peerDependencies": { "@intx/harness": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-credential-header-eee7b7e", "sha512-jCgdLZDxHSqL7PJo/xwp/xTCXTBdBgTQb4ZMBwRYCS6x29j8EPtuQiX0mvzZPRXVi02cmz45Shbc2/xQsjZ1oQ=="], @@ -770,13 +629,9 @@ "@corbits/error-sink": ["@corbits/error-sink@workspace:packages/error-sink"], - "@corbits/inference-catalog": ["@corbits/inference-catalog@workspace:packages/inference-catalog"], - - "@corbits/interaction-tools": ["@corbits/interaction-tools@workspace:tools/interaction"], - "@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#65590a8", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-65590a8", "sha512-tK7xXcL780iX3I+5R68gmyqgEA0uwYO1qdTDNn/LF6tOiFIm0lt2szjspJ8JMKMTzKC6SSai03p723rEGM1jOg=="], - "@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#e74da20", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-e74da20", "sha512-/uA9xTcF7XwMXgnR8OZamierzkLG4g4mhw3u9Fl3Uj6ait8pH3wcoQ6V32on1nMdaqtPRdYVFnxof5O+4ZacGA=="], "@corbits/myra": ["@corbits/myra@workspace:agents/myra"], @@ -788,14 +643,10 @@ "@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/skills-tools": ["@corbits/skills-tools@workspace:tools/skills"], - "@corbits/url-path": ["@corbits/url-path@workspace:packages/url-path"], "@corbits/webhooks": ["@corbits/webhooks@github:corbitsdev/webhooks#570bd52", { "dependencies": { "@intx/crypto": "^0.3.0", "@intx/db": "^0.3.0", "@intx/hub-api": "^0.3.0", "@intx/mime": "^0.3.0", "@intx/types": "^0.3.0", "hono": "4.11.9" } }, "corbitsdev-webhooks-570bd52", "sha512-GWduublrOUDgiH20a5VHFcI/zcf4mcFlQL9IEesyXKXJLekqpLFbacs4V490h0jl0oVG9KQ5dVb+CjRfTcajZA=="], - "@corbits/workflow-authoring-tools": ["@corbits/workflow-authoring-tools@workspace:tools/workflow-authoring"], - "@corbits/workflows": ["@corbits/workflows@workspace:packages/workflows"], "@corbits/xai-provider": ["@corbits/xai-provider@github:corbitsdev/corbits-xai-provider#9f2d4ba", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-xai-provider-9f2d4ba", "sha512-ZjvKxGcp+QzCh8zeXHg7kNbtkpR/itZkmQAcdG1aOAOjHaNwfGVpkPc/c/XtwPNsQyaDjF8W9CHb4rH1xI2uCA=="], @@ -896,8 +747,6 @@ "@intx/inference": ["@intx/inference@workspace:vendor/intx/inference"], - "@intx/inference-catalog": ["@intx/inference-catalog@0.3.0", "", {}, "sha512-iSimimSoIALXWWNGOG+jYmJQEQgXnWtVREB5DkSCZum/EhSTZBESUn9+aT+poB8VZAmMbeExxaiKjWWpoPM3GA=="], - "@intx/log": ["@intx/log@0.3.0", "", { "dependencies": { "@logtape/hono": "^2.0.2", "@logtape/logtape": "^2.0.2" }, "peerDependencies": { "hono": "^4.0.0" }, "optionalPeers": ["hono"] }, "sha512-iooPSZjiEUO1A91X5STRhOAk+2RprliB4nbriV/dOOz86kuxZ60y5T8ISQ5Jaj1US870gntN6QwnefY38aWi2Q=="], "@intx/mail-memory": ["@intx/mail-memory@workspace:vendor/intx/mail-memory"], @@ -1988,8 +1837,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inference-catalog/@corbits/ollama-adapter": ["@corbits/ollama-adapter@github:corbitsdev/corbits-ollama-adapter#0387eb1", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-ollama-adapter-0387eb1", "sha512-O1dl5lSwWzNGJibQLlpYDFKJZWpstgHX519bl8oe1ALL/KHszT/tBDlpsD9jN3hmQD3jV2L1jNwvOwLOBLtpyw=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -2014,8 +1861,6 @@ "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#65590a8", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-65590a8", "sha512-tK7xXcL780iX3I+5R68gmyqgEA0uwYO1qdTDNn/LF6tOiFIm0lt2szjspJ8JMKMTzKC6SSai03p723rEGM1jOg=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#e74da20", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-e74da20", "sha512-/uA9xTcF7XwMXgnR8OZamierzkLG4g4mhw3u9Fl3Uj6ait8pH3wcoQ6V32on1nMdaqtPRdYVFnxof5O+4ZacGA=="], - "@workbench/hub/@corbits/xai-provider": ["@corbits/xai-provider@github:corbitsdev/corbits-xai-provider#9f2d4ba", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-xai-provider-9f2d4ba", "sha512-ZjvKxGcp+QzCh8zeXHg7kNbtkpR/itZkmQAcdG1aOAOjHaNwfGVpkPc/c/XtwPNsQyaDjF8W9CHb4rH1xI2uCA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/docs/inference-concepts.md b/docs/inference-concepts.md deleted file mode 100644 index d8c2d19479..0000000000 --- a/docs/inference-concepts.md +++ /dev/null @@ -1,28 +0,0 @@ -# Inference concept vocabulary - -Design notes for `@corbits/inference-catalog`'s `concepts.ts`, the words an -agent uses to ask for a model. - -## Why plain data, not a table - -Pure data with no branching or network — the `CATALOG_SEEDS` idiom — so it's -importable on its own. It's deliberately not a table: this is product -vocabulary that ships with the build and needs to be reviewable in a diff. A -bench deviates through its policy row's concept ceilings and allow/deny -lists, never by forking the vocabulary. - -## Ceilings - -USD per million tokens, input and output kept separate — a blended number -would hide which axis a workload actually spends on. Ceilings are soft by -default: a model over ceiling is flagged and sorted last, not dropped, so a -bench whose only provider is expensive still gets an answer. - -## Capabilities vs. reference mix - -Capabilities are `@intx/types`' vocabulary — narrower than the pinned -catalog's, since `long-context` and `prompt-caching` are baked onto catalog -offerings but aren't storable capability values. Where a concept wants -"handles a huge input," its reference mix carries that meaning instead: a -10M-token input mix ranks the model that's cheap on enormous inputs first, -which is the decision the capability would have stood in for. diff --git a/docs/offering-capabilities.md b/docs/offering-capabilities.md deleted file mode 100644 index 915b0778e5..0000000000 --- a/docs/offering-capabilities.md +++ /dev/null @@ -1,32 +0,0 @@ -# Offering capability resolution - -How `@corbits/inference-catalog`'s `offering-capabilities.ts` decides what a -deployment can do. - -## Why this exists - -`model_offering.capabilities` is the column every capability filter reads — -this package's chain resolution and the platform's own source resolution -alike. Nothing populated it before, so it was empty everywhere. This module -is the source of truth, and it only ever reports capabilities the pinned -catalog observed on the wire: - -- `exact-deployment` — this exact (baseURL, model) was probed; use its list. -- `same-model-wire` — the model was probed on other deployments speaking the - same wire, and this one relays it; use the intersection across those - deployments, so a relay never claims more than every probed deployment - demonstrated. -- `unknown` — no probe covers it; empty list, said plainly. - -An empty list is deliberately not softened with a per-adapter baseline: "an -OpenAI-compatible endpoint serves plain text" is false for the embedding -models such endpoints also serve, and a wrong capability tag routes real -work to a model that cannot do it. - -## Storable vocabulary - -The pinned catalog's vocabulary is a superset of what the platform can -store: it bakes in `long-context` and `prompt-caching`, which `@intx/types`' -`Capability` does not accept. Everything this module reports is filtered -down to the storable vocabulary, so a seed never posts a value the hub will -reject. diff --git a/docs/ollama-context-defaults.md b/docs/ollama-context-defaults.md deleted file mode 100644 index 3a0062fe9b..0000000000 --- a/docs/ollama-context-defaults.md +++ /dev/null @@ -1,39 +0,0 @@ -# Ollama context defaults - -Why `@corbits/inference-catalog`'s `ollama-context-defaults.ts` exists and -how it plugs into inference. - -## The problem - -`@intx/inference`'s built-in OpenAI-shaped adapter defaults output tokens to -`options.maxTokens ?? 4096` when a caller sets none, and Ollama's own -OpenAI-compatible endpoint silently defaults `options.num_ctx` (its real -context window) to a small built-in size when nothing sets it either — -truncating real conversations for every locally served chat agent, with no -error to say so. - -## Where the fix lands - -`@corbits/ollama-adapter`'s `OllamaAdapterConfig` is where an override -actually reaches the built request body (`resolveOverride` and -`createOllamaAdapter`). This module supplies the real per-model values, -stored as a `model_offering`'s `quirks` column so it reaches -`InferenceSource.quirks` — and from there the adapter — through the -platform's existing resolution, with no parallel override path. - -## Table entries are native windows, not extended ceilings - -Each table entry is the model's advertised *native* context window, not a -YaRN/rope-extended ceiling: requesting a `num_ctx` past what a model (and -the host's memory) can back can fail allocation or force heavy swap, a real -operational risk rather than a convention to enforce. A model absent from -the table gets no override — Ollama's own default stands rather than this -module guessing at a ceiling it cannot back up. - -## `default` vs. `perModel` - -Each offering is already scoped to one exact model, so `quirksForDeployment` -wraps the resolved override in `default` — the field `resolveOverride` -applies when no `perModel` entry is present — rather than `perModel`, which -exists for a single shared connection serving several models through one -quirks bag. diff --git a/docs/workflow-capability-authorization.md b/docs/workflow-capability-authorization.md deleted file mode 100644 index c80ad69092..0000000000 --- a/docs/workflow-capability-authorization.md +++ /dev/null @@ -1,36 +0,0 @@ -# Workflow capability route authorization - -Why `@corbits/agent-directory`'s `workflow-capability-routes.ts` skips a -grant-store check. - -## The gap - -The vendored grant-materialization path never seeds a `kind: "workflow"` -run's own principal a `workflow-definition: /update` grant, so -`requireGrant` would 403 every self-update call a run makes for its own -definition until that vendor gap closes. - -## The interim rule - -Rather than block `request_capability` on an unpublished vendor change, -this route skips the grant-store check entirely for the one case it -accepts: a call whose authenticated run targets its own definitionId. That -narrow case is already gated by a stronger control than a grant row — -`@corbits/capability-tools`'s `request_capability` tool declares `approval: -"ask"`, so the reactor suspends every call as a pending approval and -renders it in-chat before this route ever runs. A human already had to -approve the specific addition; the human is the authorizer here, not a -grant row. - -This route still enforces, unconditionally: - -1. The caller's run must resolve to a live tenant/principal/run via the - sidecar-token + run-address check. -2. The path `definitionId` must equal the resolved run's own definitionId - (rejected 403 otherwise). -3. The addition must fail closed against the tenant's live capability - inventory. - -Once the real self-update grant is seeded in vendor grant materialization, -this route can route through `requireGrant` like every other -definition-mutating surface instead of carrying this interim rule. diff --git a/package.json b/package.json index 0678d28570..6a9f9f7f41 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,15 @@ "agents/*", "apps/*", "packages/*", - "tools/*", "vendor/intx/*", "workflows/*" ], "scripts": { "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test", "typecheck": "bun run scripts/typecheck.ts", - "lint": "oxlint apps packages tools agents workflows scripts e2e", - "fmt": "oxfmt apps packages tools agents workflows scripts e2e", - "fmt:check": "oxfmt --check apps packages tools agents workflows scripts e2e", + "lint": "oxlint apps packages agents workflows scripts e2e", + "fmt": "oxfmt apps packages agents workflows scripts e2e", + "fmt:check": "oxfmt --check apps packages agents workflows scripts e2e", "build": "bun run scripts/run-all.ts build", "test": "bun run scripts/run-all.ts test", "test:e2e": "bun test e2e", diff --git a/packages/inference-catalog/LICENSE b/packages/inference-catalog/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/packages/inference-catalog/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/inference-catalog/README.md b/packages/inference-catalog/README.md deleted file mode 100644 index ccaf9d2c33..0000000000 --- a/packages/inference-catalog/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# @corbits/inference-catalog - -Agents ask for a model by what the work needs, never by name. - -A **concept** — `cheap-loop`, `code-work`, `image-reader` — names a kind of -work. Resolving one against a bench gives back an ordered **chain**: the -models this bench can actually reach that can do that work, cheapest first, -with fallbacks behind the head. A chain, never a single model, because one -model is not a fallback plan. - -## What it owns - -One table, `inference_catalog.bench_model_policy`: a bench's allow and deny -lists, its price ceilings, and its provider preference. A bench that has -never set one is unconstrained, which is what makes a freshly connected -bench work with no configuration at all. - -Everything else is derived at read time from the platform's own catalog: - -| Answer | Comes from | -| -------------------------- | ---------------------------------------------------------------------------- | -| what this bench can reach | `listVisibleOfferings` ∩ providers with a credential | -| what a model can do | `model_offering.capabilities` | -| what it costs | `model_pricing` → `resolveActivePrice`, normalized to USD per million tokens | -| which models fit a concept | the offering advertises every capability the concept requires | -| the order | cheapest first for the concept's own token mix, then catalog priority | - -## The modules - -- `concepts.ts` — the shipped vocabulary, as data. Twelve concepts, each with - what it requires, a soft price ceiling, and the token mix that defines - "cheapest" for it. -- `resolve-chain.ts` — `resolveModelChain`, pure: every read is done by the - caller and handed in. Also `chainToModelRequirements`, the hand-off to the - platform's own source resolution. -- `offering-capabilities.ts` — `capabilitiesForDeployment`, what to store on - a newly created offering, read from the pinned catalog's probe results. -- `price.ts` — per-token decimal strings in, USD per million tokens out. The - only place that conversion happens. -- `store.ts` / `pg-store.ts` / `routes.ts` — the policy row. -- `workflow-catalog-routes.ts` — the run-authenticated surface - `@corbits/catalog-tools` calls. - -## Rules it holds to - -- **Never a fabricated price.** A missing or partial price row is reported as - unknown, never as zero — a zero would read as free and win every sort. -- **Ceilings are soft.** An over-ceiling model is flagged and sorted last, - not dropped, so a bench whose only provider is expensive still gets an - answer. A bench can make its own ceiling hard. -- **An empty chain stays empty.** It comes back with the reasons each - candidate was excluded, so the caller can say what is missing rather than - substitute something that cannot do the work. -- **It never resolves credentials.** Its output projects to - `ModelRequirement[]`; only the platform turns those into runnable sources. diff --git a/packages/inference-catalog/package.json b/packages/inference-catalog/package.json deleted file mode 100644 index 93904a0f7d..0000000000 --- a/packages/inference-catalog/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "@corbits/inference-catalog", - "version": "0.1.0", - "description": "Model concepts and chain resolution: agents ask for what the work needs (a concept or a capability set) and get an ordered, priced fallback chain of the models this bench can actually reach — never a single model, never a model named by hand.", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "packages/inference-catalog" - }, - "files": [ - "src", - "!src/**/*.test.ts", - "!src/**/*.test.tsx", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts", - "./catalog": "./src/catalog.ts", - "./concepts": "./src/concepts.ts", - "./policy": "./src/policy.ts", - "./price": "./src/price.ts", - "./resolve-chain": "./src/resolve-chain.ts", - "./offering-capabilities": "./src/offering-capabilities.ts", - "./ollama-context-defaults": "./src/ollama-context-defaults.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/ollama-adapter": "github:corbitsdev/corbits-ollama-adapter#0387eb17bee5ac87530e9c48bb3bffd9a6998ded", - "@intx/inference-catalog": "0.3.0", - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/types": "0.3.0" - } -} diff --git a/packages/inference-catalog/src/capabilities.ts b/packages/inference-catalog/src/capabilities.ts deleted file mode 100644 index 216dad2bc4..0000000000 --- a/packages/inference-catalog/src/capabilities.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Re-exported verbatim so this package has one import point without -// declaring a second vocabulary that could drift from the platform's. -export { Capability, WIRE_CAPABILITIES } from "@intx/types"; diff --git a/packages/inference-catalog/src/catalog.ts b/packages/inference-catalog/src/catalog.ts deleted file mode 100644 index f2305374ec..0000000000 --- a/packages/inference-catalog/src/catalog.ts +++ /dev/null @@ -1,69 +0,0 @@ -// The catalog shape chain resolution works on: the platform's model -// discovery response, parsed. Nothing here reaches a database. -import { type } from "arktype"; - -import { Capability } from "./capabilities"; - -/** One active price row for an offering, prices per token as decimal - * strings — the column's own units, normalized to USD per million tokens - * in `price.ts` and nowhere else. */ -export const CatalogPricingRow = type({ - offeringId: "string", - currency: "string", - "inputTokenPrice?": "string | null", - "outputTokenPrice?": "string | null", -}); -export type CatalogPricingRow = typeof CatalogPricingRow.infer; - -export const DiscoveredModelOffering = type({ - offeringId: "string", - providerName: "string", - plugin: "string", - priority: "number", - capabilities: Capability.array(), - pricing: CatalogPricingRow.array(), -}); - -export const DiscoveredModel = type({ - id: "string", - canonicalName: "string", - "displayName?": "string | null", - offerings: DiscoveredModelOffering.array(), -}); -export type DiscoveredModel = typeof DiscoveredModel.infer; - -/** One model on one provider, the unit chain resolution ranks. */ -export type CatalogOffering = { - readonly offeringId: string; - readonly canonicalName: string; - readonly displayName: string | null; - readonly providerName: string; - readonly plugin: string; - readonly priority: number; - readonly capabilities: readonly Capability[]; -}; - -/** Flattens the discovery response into the resolver's two inputs: one row - * per (model, provider) pairing, and the price rows those offerings carry. */ -export function catalogOfferingsFrom(models: readonly DiscoveredModel[]): { - readonly offerings: readonly CatalogOffering[]; - readonly pricing: readonly CatalogPricingRow[]; -} { - const offerings: CatalogOffering[] = []; - const pricing: CatalogPricingRow[] = []; - for (const model of models) { - for (const entry of model.offerings) { - offerings.push({ - offeringId: entry.offeringId, - canonicalName: model.canonicalName, - displayName: model.displayName ?? null, - providerName: entry.providerName, - plugin: entry.plugin, - priority: entry.priority, - capabilities: entry.capabilities, - }); - pricing.push(...entry.pricing); - } - } - return { offerings, pricing }; -} diff --git a/packages/inference-catalog/src/concepts.test.ts b/packages/inference-catalog/src/concepts.test.ts deleted file mode 100644 index 8de53408cd..0000000000 --- a/packages/inference-catalog/src/concepts.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { WIRE_CAPABILITIES } from "@intx/types"; - -import { CONCEPTS, CONCEPT_IDS, conceptById, DEFAULT_MIX } from "./concepts"; - -const vocabulary = new Set(WIRE_CAPABILITIES); - -describe("CONCEPTS", () => { - test("every required and preferred capability is in the platform vocabulary", () => { - for (const concept of CONCEPTS) { - for (const capability of [...concept.requires, ...concept.prefers]) { - expect({ - concept: concept.id, - capability, - real: vocabulary.has(capability), - }).toEqual({ - concept: concept.id, - capability, - real: true, - }); - } - } - }); - - test("ids are unique and kebab-case", () => { - expect(new Set(CONCEPT_IDS).size).toBe(CONCEPTS.length); - for (const id of CONCEPT_IDS) expect(id).toMatch(/^[a-z]+(-[a-z]+)*$/); - }); - - test("every concept requires at least one capability", () => { - for (const concept of CONCEPTS) { - expect(concept.requires.length).toBeGreaterThan(0); - } - }); - - test("ceilings and reference mixes are positive", () => { - for (const concept of CONCEPTS) { - expect(concept.ceiling.maxInputUsdPerMTok).toBeGreaterThan(0); - expect(concept.ceiling.maxOutputUsdPerMTok).toBeGreaterThan(0); - expect(concept.referenceMix.inputMTok).toBeGreaterThan(0); - expect(concept.referenceMix.outputMTok).toBeGreaterThan(0); - } - }); - - test("every concept says when to use it, in one plain sentence", () => { - for (const concept of CONCEPTS) { - expect(concept.whenToUse.length).toBeGreaterThan(10); - expect(concept.whenToUse.endsWith(".")).toBe(true); - } - }); - - test("code-work prefers code execution rather than requiring it", () => { - const codeWork = conceptById("code-work"); - expect(codeWork?.prefers).toContain("code-execution"); - expect(codeWork?.requires).not.toContain("code-execution"); - }); - - test("conceptById returns undefined for a name nobody shipped", () => { - expect(conceptById("vibes-based")).toBeUndefined(); - }); - - test("the default mix is input-heavy", () => { - expect(DEFAULT_MIX.inputMTok).toBeGreaterThan(DEFAULT_MIX.outputMTok); - }); -}); diff --git a/packages/inference-catalog/src/concepts.ts b/packages/inference-catalog/src/concepts.ts deleted file mode 100644 index c64307c3d3..0000000000 --- a/packages/inference-catalog/src/concepts.ts +++ /dev/null @@ -1,152 +0,0 @@ -// The concept vocabulary: the words an agent uses to ask for a model. -// Design rationale: docs/inference-concepts.md. -import type { Capability } from "@intx/types"; - -/** The token mix that defines "cheapest" for a concept, in millions of - * tokens. An input-heavy concept ranks a cheap-input model first; an - * output-heavy one ranks cheap output first. */ -export type ReferenceMix = { - readonly inputMTok: number; - readonly outputMTok: number; -}; - -export type ConceptCeiling = { - readonly maxInputUsdPerMTok: number; - readonly maxOutputUsdPerMTok: number; -}; - -export type ConceptSpec = { - /** Stable kebab-case slug; the id an agent asks by. */ - readonly id: string; - readonly title: string; - /** One sentence in the reader's language, shown to agents choosing. */ - readonly whenToUse: string; - /** An offering must advertise every one of these to qualify. */ - readonly requires: readonly Capability[]; - /** Bonus capabilities: they break ties, they never filter. */ - readonly prefers: readonly Capability[]; - readonly ceiling: ConceptCeiling; - readonly referenceMix: ReferenceMix; -}; - -/** The mix used when the caller asks by raw capabilities rather than by - * concept: mostly-input work with a short answer, the shape of the median - * request. No ceiling applies to a bare capability ask. */ -export const DEFAULT_MIX: ReferenceMix = { inputMTok: 1, outputMTok: 0.25 }; - -export const CONCEPTS: readonly ConceptSpec[] = [ - { - id: "cheap-loop", - title: "Cheap loop", - whenToUse: "A step that runs hundreds of times: classify, tag, route.", - requires: ["plain-text"], - prefers: ["structured-output"], - ceiling: { maxInputUsdPerMTok: 0.3, maxOutputUsdPerMTok: 1.0 }, - referenceMix: { inputMTok: 1, outputMTok: 0.05 }, - }, - { - id: "bulk-extraction", - title: "Bulk extraction", - whenToUse: "Pull fields out of a pile of documents into a fixed shape.", - requires: ["plain-text", "structured-output"], - prefers: ["document-input"], - ceiling: { maxInputUsdPerMTok: 0.6, maxOutputUsdPerMTok: 2.5 }, - referenceMix: { inputMTok: 4, outputMTok: 0.1 }, - }, - { - id: "long-document", - title: "Long document", - whenToUse: "Read something very large end to end and answer about it.", - requires: ["plain-text"], - prefers: ["document-input", "structured-output"], - ceiling: { maxInputUsdPerMTok: 3.0, maxOutputUsdPerMTok: 12.0 }, - referenceMix: { inputMTok: 10, outputMTok: 0.25 }, - }, - { - id: "judgment-heavy", - title: "Judgment heavy", - whenToUse: "Hard calls where being right matters more than being cheap.", - requires: ["plain-text", "reasoning-content"], - prefers: ["function-calling-with-thinking"], - ceiling: { maxInputUsdPerMTok: 6.0, maxOutputUsdPerMTok: 30.0 }, - referenceMix: { inputMTok: 1, outputMTok: 1 }, - }, - { - id: "everyday-assistant", - title: "Everyday assistant", - whenToUse: "Live chat with a person, using tools as it goes.", - requires: ["plain-text-streaming", "function-calling-multi-turn-streaming"], - prefers: ["vision-input"], - ceiling: { maxInputUsdPerMTok: 3.0, maxOutputUsdPerMTok: 15.0 }, - referenceMix: { inputMTok: 2, outputMTok: 0.5 }, - }, - { - id: "agentic-workhorse", - title: "Agentic workhorse", - whenToUse: "Multi-step tool work run unattended to completion.", - requires: ["function-calling-multi-turn", "structured-output"], - prefers: ["reasoning-content"], - ceiling: { maxInputUsdPerMTok: 3.0, maxOutputUsdPerMTok: 15.0 }, - referenceMix: { inputMTok: 3, outputMTok: 1 }, - }, - { - id: "deep-tool-reasoner", - title: "Deep tool reasoner", - whenToUse: "Long autonomous work where a wrong tool call is expensive.", - requires: ["function-calling-with-thinking", "reasoning-content"], - prefers: ["redacted-thinking"], - ceiling: { maxInputUsdPerMTok: 8.0, maxOutputUsdPerMTok: 40.0 }, - referenceMix: { inputMTok: 3, outputMTok: 1 }, - }, - { - id: "code-work", - title: "Code work", - whenToUse: "Read, write and run code against a repository.", - requires: ["function-calling-multi-turn", "structured-output"], - prefers: ["code-execution", "reasoning-content"], - ceiling: { maxInputUsdPerMTok: 4.0, maxOutputUsdPerMTok: 20.0 }, - referenceMix: { inputMTok: 5, outputMTok: 1 }, - }, - { - id: "image-reader", - title: "Image reader", - whenToUse: "Screenshots, scans, diagrams — describe or extract from them.", - requires: ["vision-input", "plain-text"], - prefers: ["document-input", "structured-output"], - ceiling: { maxInputUsdPerMTok: 3.0, maxOutputUsdPerMTok: 12.0 }, - referenceMix: { inputMTok: 2, outputMTok: 0.25 }, - }, - { - id: "image-maker", - title: "Image maker", - whenToUse: "Produce a picture rather than words.", - requires: ["image-output"], - prefers: ["vision-input"], - ceiling: { maxInputUsdPerMTok: 2.0, maxOutputUsdPerMTok: 10.0 }, - referenceMix: { inputMTok: 1, outputMTok: 0.5 }, - }, - { - id: "voice-in", - title: "Voice in", - whenToUse: "Take spoken input — a recording or a live call.", - requires: ["audio-input"], - prefers: ["audio-input-streaming", "structured-output"], - ceiling: { maxInputUsdPerMTok: 2.0, maxOutputUsdPerMTok: 10.0 }, - referenceMix: { inputMTok: 2, outputMTok: 0.5 }, - }, - { - id: "fresh-facts", - title: "Fresh facts", - whenToUse: "Answers that must reflect the world right now, with sources.", - requires: ["grounding", "plain-text"], - prefers: ["grounding-streaming", "structured-output"], - ceiling: { maxInputUsdPerMTok: 4.0, maxOutputUsdPerMTok: 16.0 }, - referenceMix: { inputMTok: 2, outputMTok: 0.5 }, - }, -]; - -export const CONCEPT_IDS: readonly string[] = CONCEPTS.map((concept) => concept.id); - -export function conceptById(id: string): ConceptSpec | undefined { - return CONCEPTS.find((concept) => concept.id === id); -} diff --git a/packages/inference-catalog/src/fixtures.ts b/packages/inference-catalog/src/fixtures.ts deleted file mode 100644 index dda23d9552..0000000000 --- a/packages/inference-catalog/src/fixtures.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Plain catalog-row literals so the resolution suites exercise the real -// algorithm with no database anywhere near them — the platform's model -// discovery response, already flattened, as `catalog.ts` produces it. -import type { Capability } from "@intx/types"; - -import type { CatalogOffering, CatalogPricingRow } from "../src/catalog"; - -export type OfferingFixture = { - id: string; - canonicalName: string; - displayName?: string | null; - providerName: string; - plugin?: string; - capabilities: Capability[]; - priority?: number; -}; - -export function offering(fixture: OfferingFixture): CatalogOffering { - return { - offeringId: fixture.id, - canonicalName: fixture.canonicalName, - displayName: fixture.displayName ?? null, - providerName: fixture.providerName, - plugin: fixture.plugin ?? "openai-compatible", - priority: fixture.priority ?? 0, - capabilities: fixture.capabilities, - }; -} - -export type PricingFixture = { - offeringId: string; - /** USD per million tokens; stored per-token, as the column requires. */ - inputUsdPerMTok: number | null; - outputUsdPerMTok: number | null; - currency?: string; -}; - -export function pricing(fixture: PricingFixture): CatalogPricingRow { - const perToken = (perMTok: number | null): string | null => - perMTok === null ? null : String(perMTok / 1_000_000); - return { - offeringId: fixture.offeringId, - currency: fixture.currency ?? "USD", - inputTokenPrice: perToken(fixture.inputUsdPerMTok), - outputTokenPrice: perToken(fixture.outputUsdPerMTok), - }; -} diff --git a/packages/inference-catalog/src/index.ts b/packages/inference-catalog/src/index.ts deleted file mode 100644 index b6dc5cb218..0000000000 --- a/packages/inference-catalog/src/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -export { Capability, WIRE_CAPABILITIES } from "./capabilities"; -export { - catalogOfferingsFrom, - CatalogPricingRow, - DiscoveredModel, - type CatalogOffering, -} from "./catalog"; -export { - CONCEPTS, - CONCEPT_IDS, - DEFAULT_MIX, - conceptById, - type ConceptCeiling, - type ConceptSpec, - type ReferenceMix, -} from "./concepts"; -export { - capabilitiesForDeployment, - type CapabilityProvenance, - type DeploymentIdentity, - type OfferingCapabilities, -} from "./offering-capabilities"; -export { - DEFAULT_CURRENCY, - estimateUsd, - groupPricingByOffering, - perMTok, - priceForOffering, - referenceCostUsd, - type OfferingPrice, -} from "./price"; -export { - EMPTY_POLICY, - MODEL_POLICY_CONFIG_PATH, - matchesAny, - readModelPolicy, - selectorMatches, - StoredModelPolicy, - type BenchModelPolicy, - type PolicyCeiling, - type PolicySelector, -} from "./policy"; -export { - chainToModelRequirements, - resolveModelChain, - UnknownConceptError, - type ChainEntry, - type ChainNeed, - type ChainOrder, - type ExclusionReason, - type ModelChain, - type ResolveChainInput, -} from "./resolve-chain"; diff --git a/packages/inference-catalog/src/offering-capabilities.test.ts b/packages/inference-catalog/src/offering-capabilities.test.ts deleted file mode 100644 index 9e1df6e59c..0000000000 --- a/packages/inference-catalog/src/offering-capabilities.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { WIRE_CAPABILITIES } from "@intx/types"; - -import { capabilitiesForDeployment } from "./offering-capabilities"; - -const vocabulary = new Set(WIRE_CAPABILITIES); - -describe("capabilitiesForDeployment", () => { - test("a probed deployment reports what the catalog observed on it", () => { - const resolved = capabilitiesForDeployment({ - plugin: "anthropic", - baseURL: "https://api.anthropic.com", - canonicalName: "claude-sonnet-5", - }); - expect(resolved.provenance).toBe("exact-deployment"); - expect(resolved.capabilities).toContain("plain-text"); - expect(resolved.capabilities).toContain("function-calling-multi-turn"); - }); - - test("a relay of a model probed on the same wire inherits the shared floor", () => { - const resolved = capabilitiesForDeployment({ - plugin: "openai-compatible", - baseURL: "https://openrouter.ai/api/v1", - canonicalName: "openai/gpt-5.6-sol", - }); - expect(resolved.provenance).toBe("same-model-wire"); - expect(resolved.capabilities.length).toBeGreaterThan(0); - }); - - test("a Claude model relayed over the OpenAI wire never inherits the native adapter's list", () => { - const resolved = capabilitiesForDeployment({ - plugin: "openai-compatible", - baseURL: "https://opencode.ai/zen/v1", - canonicalName: "claude-sonnet-5", - }); - expect(resolved.provenance).toBe("unknown"); - expect(resolved.capabilities).toEqual([]); - }); - - test("an unprobed local deployment says it does not know, rather than guessing", () => { - const resolved = capabilitiesForDeployment({ - plugin: "openai-compatible", - baseURL: "http://localhost:11434/v1", - canonicalName: "qwen3.8:27b", - }); - expect(resolved).toEqual({ capabilities: [], provenance: "unknown" }); - }); - - test("every capability it ever reports is in the platform vocabulary", () => { - for (const deployment of [ - { - plugin: "anthropic", - baseURL: "https://api.anthropic.com", - canonicalName: "claude-opus-5", - }, - { - plugin: "openai", - baseURL: "https://api.openai.com/v1", - canonicalName: "gpt-4o-mini", - }, - { - plugin: "google-genai", - baseURL: "https://generativelanguage.googleapis.com", - canonicalName: "gemini-2.5-flash", - }, - { - plugin: "openai-compatible", - baseURL: "https://api.x.ai/v1", - canonicalName: "grok-4.6", - }, - ]) { - for (const capability of capabilitiesForDeployment(deployment).capabilities) { - expect(vocabulary.has(capability)).toBe(true); - } - } - }); -}); diff --git a/packages/inference-catalog/src/offering-capabilities.ts b/packages/inference-catalog/src/offering-capabilities.ts deleted file mode 100644 index 7331235f3f..0000000000 --- a/packages/inference-catalog/src/offering-capabilities.ts +++ /dev/null @@ -1,82 +0,0 @@ -// What a deployment can actually do, resolved from the pinned catalog at the -// moment an offering is created. Full rationale: docs/offering-capabilities.md. -import { catalogProviders } from "@intx/inference-catalog"; -import { WIRE_CAPABILITIES, type Capability } from "@intx/types"; - -export type CapabilityProvenance = "exact-deployment" | "same-model-wire" | "unknown"; - -export type OfferingCapabilities = { - readonly capabilities: readonly Capability[]; - readonly provenance: CapabilityProvenance; -}; - -export type DeploymentIdentity = { - /** The adapter that serves this provider, as stored on `model_provider`. */ - readonly plugin: string; - readonly baseURL: string; - readonly canonicalName: string; -}; - -/** OpenAI Direct and every openai-compatible relay speak one wire; other - * adapters speak their own, since a relay genuinely offers less. */ -function wireFamily(plugin: string): string { - return plugin === "openai" || plugin === "openai-compatible" ? "openai-wire" : plugin; -} - -/** Relays namespace a model by its originating vendor (`openai/gpt-5.6-sol`, - * `deepseek-ai/DeepSeek-V4-Flash`). The trailing segment is the model the - * catalog probed. */ -function relayModelName(canonicalName: string): string { - const lastSlash = canonicalName.lastIndexOf("/"); - return lastSlash === -1 ? canonicalName : canonicalName.slice(lastSlash + 1); -} - -function intersect(lists: readonly (readonly string[])[]): readonly string[] { - const [first, ...rest] = lists; - if (first === undefined) return []; - return first.filter((capability) => rest.every((list) => list.includes(capability))); -} - -const STORABLE = new Set(WIRE_CAPABILITIES); - -function storable(capabilities: readonly string[]): readonly Capability[] { - return capabilities.filter((capability): capability is Capability => STORABLE.has(capability)); -} - -/** - * The capabilities to store on a newly created offering. Deterministic and - * dependency-free: it reads the pinned catalog literals only, never a - * network probe. - */ -export function capabilitiesForDeployment(deployment: DeploymentIdentity): OfferingCapabilities { - for (const provider of catalogProviders) { - if (provider.baseURL !== deployment.baseURL) continue; - const offering = provider.offerings.find( - (candidate) => candidate.model === deployment.canonicalName, - ); - if (offering !== undefined) { - return { - capabilities: storable(offering.capabilities), - provenance: "exact-deployment", - }; - } - } - - const family = wireFamily(deployment.plugin); - const modelName = relayModelName(deployment.canonicalName); - const sameWire: (readonly string[])[] = []; - for (const provider of catalogProviders) { - if (wireFamily(provider.plugin) !== family) continue; - for (const offering of provider.offerings) { - if (offering.model === modelName) sameWire.push(offering.capabilities); - } - } - if (sameWire.length > 0) { - return { - capabilities: storable(intersect(sameWire)), - provenance: "same-model-wire", - }; - } - - return { capabilities: [], provenance: "unknown" }; -} diff --git a/packages/inference-catalog/src/ollama-context-defaults.test.ts b/packages/inference-catalog/src/ollama-context-defaults.test.ts deleted file mode 100644 index 9968d3be8f..0000000000 --- a/packages/inference-catalog/src/ollama-context-defaults.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createOllamaAdapter } from "@corbits/ollama-adapter"; -import type { LastCycleSource } from "@intx/types/runtime"; -import type { ConversationTurn, InferenceOptions } from "@intx/types/runtime"; - -import { OLLAMA_MODEL_DEFAULTS, quirksForDeployment } from "./ollama-context-defaults"; - -describe("quirksForDeployment", () => { - test("a curated Ollama model resolves its advertised native context window", () => { - const quirks = quirksForDeployment({ - providerName: "ollama", - canonicalName: "gpt-oss:20b", - }); - const gptOssDefault = OLLAMA_MODEL_DEFAULTS["gpt-oss:20b"]; - if (gptOssDefault === undefined) throw new Error("missing fixture entry"); - expect(quirks).toEqual({ default: gptOssDefault }); - expect(quirks?.default?.numCtx).toBeGreaterThanOrEqual(131_072); - }); - - test("a model with a smaller real ceiling gets its own limit, not the larger model's", () => { - const bigModel = quirksForDeployment({ - providerName: "ollama", - canonicalName: "llama3.1:8b", - }); - const smallerModel = quirksForDeployment({ - providerName: "ollama", - canonicalName: "qwen3.8:27b", - }); - expect(bigModel?.default?.numCtx).toBe(131_072); - expect(smallerModel?.default?.numCtx).toBe(32_768); - expect(smallerModel?.default?.numCtx).toBeLessThan(bigModel?.default?.numCtx ?? 0); - }); - - test("a caller-supplied override wins over the built-in table", () => { - const quirks = quirksForDeployment( - { providerName: "ollama", canonicalName: "gpt-oss:20b" }, - { "gpt-oss:20b": { numCtx: 8192 } }, - ); - expect(quirks).toEqual({ default: { numCtx: 8192 } }); - }); - - test("a model absent from the table gets no override, never a guessed number", () => { - const quirks = quirksForDeployment({ - providerName: "ollama", - canonicalName: "some-future-model:1b", - }); - expect(quirks).toBeUndefined(); - }); - - test("a non-Ollama provider is left untouched even on the same openai-compatible wire", () => { - const quirks = quirksForDeployment({ - providerName: "groq", - canonicalName: "gpt-oss:20b", - }); - expect(quirks).toBeUndefined(); - }); -}); - -describe("the resolved quirks reach the built Ollama request body", () => { - const source: LastCycleSource = { - sourceId: "ollama-test", - provider: "ollama", - model: "gpt-oss:20b", - }; - const messages: ConversationTurn[] = [ - { role: "user", content: [{ type: "text", text: "hello" }], timestamp: 0 }, - ]; - const options: InferenceOptions = {}; - - test("gpt-oss:20b's request carries its 128K context window, not the built-in 4096 default", () => { - const quirks = quirksForDeployment({ - providerName: "ollama", - canonicalName: "gpt-oss:20b", - }); - const adapter = createOllamaAdapter(source, quirks); - const built = adapter.buildRequest(messages, "gpt-oss:20b", options); - const body = JSON.parse(built.body) as Record; - expect(body["options"]).toEqual({ num_ctx: 131_072 }); - expect(body["max_tokens"]).toBe(32_768); - expect(body["max_tokens"]).not.toBe(4096); - }); - - test("qwen3.8:27b's request carries its own smaller real ceiling", () => { - const quirks = quirksForDeployment({ - providerName: "ollama", - canonicalName: "qwen3.8:27b", - }); - const adapter = createOllamaAdapter(source, quirks); - const built = adapter.buildRequest(messages, "qwen3.8:27b", options); - const body = JSON.parse(built.body) as Record; - expect(body["options"]).toEqual({ num_ctx: 32_768 }); - }); -}); diff --git a/packages/inference-catalog/src/ollama-context-defaults.ts b/packages/inference-catalog/src/ollama-context-defaults.ts deleted file mode 100644 index 1fe1643352..0000000000 --- a/packages/inference-catalog/src/ollama-context-defaults.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Per-model context-window and output-token defaults for locally served -// Ollama models. See docs/ollama-context-defaults.md. -import type { OllamaAdapterConfig, OllamaAdapterOverride } from "@corbits/ollama-adapter"; - -export type OllamaModelDefaults = Readonly>; - -export const OLLAMA_MODEL_DEFAULTS: OllamaModelDefaults = { - // OpenAI gpt-oss: 128K native context window. - "gpt-oss:20b": { numCtx: 131_072, maxOutputTokens: 32_768 }, - // Qwen3's 27B/30B-class models: 32K native context window. Reaching 128K - // on this family requires YaRN rope scaling, which this table does not - // treat as "genuinely" supported per-model context. - "qwen3.8:27b": { numCtx: 32_768, maxOutputTokens: 8192 }, - "qwen3.5:9b-mlx": { numCtx: 32_768, maxOutputTokens: 8192 }, - // Meta Llama 3.1: 128K native context window. - "llama3.1:8b": { numCtx: 131_072, maxOutputTokens: 8192 }, -}; - -export type OllamaDeploymentIdentity = { - readonly providerName: string; - readonly canonicalName: string; -}; - -/** The `quirks` value to store on a newly created Ollama offering; undefined - * for a non-Ollama provider or an unvetted model. See - * docs/ollama-context-defaults.md#default-vs-permodel. */ -export function quirksForDeployment( - deployment: OllamaDeploymentIdentity, - overrides: OllamaModelDefaults = {}, -): OllamaAdapterConfig | undefined { - if (deployment.providerName !== "ollama") return undefined; - const resolved = - overrides[deployment.canonicalName] ?? OLLAMA_MODEL_DEFAULTS[deployment.canonicalName]; - if (resolved === undefined) return undefined; - return { default: resolved }; -} diff --git a/packages/inference-catalog/src/policy.ts b/packages/inference-catalog/src/policy.ts deleted file mode 100644 index e371077885..0000000000 --- a/packages/inference-catalog/src/policy.ts +++ /dev/null @@ -1,116 +0,0 @@ -// A bench's model policy: what it is willing to spend, and on which models. -// Lives in tenant `config` under `corbits.modelPolicy` — no product table. -import { type } from "arktype"; -import { ProviderPreference } from "@intx/types"; - -/** An allow/deny entry. Matched, in this order, as an exact canonical model - * name, `provider:`, or `/`. */ -export type PolicySelector = string; - -export type PolicyCeiling = { - readonly maxInputUsdPerMTok: number | null; - readonly maxOutputUsdPerMTok: number | null; -}; - -export type BenchModelPolicy = { - readonly allow: readonly PolicySelector[]; - readonly deny: readonly PolicySelector[]; - readonly maxInputUsdPerMTok: number | null; - readonly maxOutputUsdPerMTok: number | null; - /** `false` sorts over-ceiling models last and flags them; `true` excludes - * them outright. Concept ceilings stay soft either way. */ - readonly ceilingIsHard: boolean; - /** Per-concept overrides of the shipped ceilings, keyed by concept id. */ - readonly conceptCeilings: Readonly>; - readonly providerPreference: ProviderPreference | null; -}; - -export const EMPTY_POLICY: BenchModelPolicy = { - allow: [], - deny: [], - maxInputUsdPerMTok: null, - maxOutputUsdPerMTok: null, - ceilingIsHard: false, - conceptCeilings: {}, - providerPreference: null, -}; - -const PolicyCeilingBody = type({ - "maxInputUsdPerMTok?": "number >= 0 | null", - "maxOutputUsdPerMTok?": "number >= 0 | null", -}); - -/** The stored shape, under `config.corbits.modelPolicy`. Every field is - * optional: a partially written key is read as the parts it does carry, - * with EMPTY_POLICY supplying the rest. */ -export const StoredModelPolicy = type({ - "allow?": "string[]", - "deny?": "string[]", - "maxInputUsdPerMTok?": "number >= 0 | null", - "maxOutputUsdPerMTok?": "number >= 0 | null", - "ceilingIsHard?": "boolean", - "conceptCeilings?": type.Record("string", PolicyCeilingBody), - "providerPreference?": ProviderPreference.or("null"), -}); - -const CorbitsTenantConfig = type({ - "corbits?": { - "modelPolicy?": StoredModelPolicy, - "[string]": "unknown", - }, - "[string]": "unknown", -}); - -/** The key path a policy is stored and read under, as the stock PATCH body - * spells it. */ -export const MODEL_POLICY_CONFIG_PATH = ["corbits", "modelPolicy"] as const; - -/** This bench's model policy. Anything the schema rejects reads as the parts - * that do parse over EMPTY_POLICY — a malformed blob leaves the bench - * unconstrained, never unable to pick a model. */ -export function readModelPolicy(tenantConfig: unknown): BenchModelPolicy { - const parsed = CorbitsTenantConfig(tenantConfig); - if (parsed instanceof type.errors) return EMPTY_POLICY; - const stored = parsed.corbits?.modelPolicy; - if (stored === undefined) return EMPTY_POLICY; - return { - allow: stored.allow ?? EMPTY_POLICY.allow, - deny: stored.deny ?? EMPTY_POLICY.deny, - maxInputUsdPerMTok: stored.maxInputUsdPerMTok ?? null, - maxOutputUsdPerMTok: stored.maxOutputUsdPerMTok ?? null, - ceilingIsHard: stored.ceilingIsHard ?? EMPTY_POLICY.ceilingIsHard, - conceptCeilings: readConceptCeilings(stored.conceptCeilings), - providerPreference: stored.providerPreference ?? null, - }; -} - -function readConceptCeilings( - stored: Record | undefined, -): Readonly> { - if (stored === undefined) return EMPTY_POLICY.conceptCeilings; - const ceilings: Record = {}; - for (const [concept, ceiling] of Object.entries(stored)) { - ceilings[concept] = { - maxInputUsdPerMTok: ceiling.maxInputUsdPerMTok ?? null, - maxOutputUsdPerMTok: ceiling.maxOutputUsdPerMTok ?? null, - }; - } - return ceilings; -} - -/** True when `selector` names this offering, under any of the three forms. */ -export function selectorMatches( - selector: PolicySelector, - offering: { readonly canonicalName: string; readonly providerName: string }, -): boolean { - if (selector === offering.canonicalName) return true; - if (selector === `provider:${offering.providerName}`) return true; - return selector === `${offering.providerName}/${offering.canonicalName}`; -} - -export function matchesAny( - selectors: readonly PolicySelector[], - offering: { readonly canonicalName: string; readonly providerName: string }, -): boolean { - return selectors.some((selector) => selectorMatches(selector, offering)); -} diff --git a/packages/inference-catalog/src/price.test.ts b/packages/inference-catalog/src/price.test.ts deleted file mode 100644 index 0c09c7b4b5..0000000000 --- a/packages/inference-catalog/src/price.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { pricing } from "./fixtures"; -import { groupPricingByOffering, perMTok, priceForOffering, referenceCostUsd } from "./price"; - -describe("perMTok", () => { - test("scales a per-token decimal string to USD per million tokens", () => { - expect(perMTok("0.000003")).toBeCloseTo(3, 10); - }); - - test("returns null for an absent price rather than zero", () => { - expect(perMTok(null)).toBeNull(); - }); - - test("returns null for a value that is not a number", () => { - expect(perMTok("free")).toBeNull(); - }); -}); - -describe("priceForOffering", () => { - test("takes the row in the requested currency", () => { - const price = priceForOffering( - [pricing({ offeringId: "o1", inputUsdPerMTok: 2, outputUsdPerMTok: 10 })], - "USD", - ); - expect(price.inputUsdPerMTok).toBeCloseTo(2, 10); - expect(price.outputUsdPerMTok).toBeCloseTo(10, 10); - expect(price.known).toBe(true); - }); - - test("a row in another currency is not a price in this one", () => { - const price = priceForOffering( - [ - pricing({ - offeringId: "o1", - inputUsdPerMTok: 3, - outputUsdPerMTok: 15, - currency: "EUR", - }), - ], - "USD", - ); - expect(price).toEqual({ - currency: "USD", - known: false, - inputUsdPerMTok: null, - outputUsdPerMTok: null, - }); - }); - - test("no rows at all is unknown, never zero", () => { - const price = priceForOffering([], "USD"); - expect(price.known).toBe(false); - expect(price.inputUsdPerMTok).toBeNull(); - }); - - test("a half-priced row is not a known price", () => { - const price = priceForOffering( - [ - pricing({ - offeringId: "o1", - inputUsdPerMTok: 3, - outputUsdPerMTok: null, - }), - ], - "USD", - ); - expect(price.known).toBe(false); - }); -}); - -describe("referenceCostUsd", () => { - test("weights the two axes by the concept's mix", () => { - const price = priceForOffering( - [pricing({ offeringId: "o1", inputUsdPerMTok: 3, outputUsdPerMTok: 15 })], - "USD", - ); - expect(referenceCostUsd(price, { inputMTok: 2, outputMTok: 0.5 })).toBeCloseTo(13.5, 6); - }); - - test("an unknown price has no reference cost", () => { - expect( - referenceCostUsd( - { - currency: "USD", - known: false, - inputUsdPerMTok: null, - outputUsdPerMTok: null, - }, - { inputMTok: 1, outputMTok: 1 }, - ), - ).toBeNull(); - }); -}); - -describe("groupPricingByOffering", () => { - test("keeps every row for an offering together", () => { - const grouped = groupPricingByOffering([ - pricing({ offeringId: "o1", inputUsdPerMTok: 1, outputUsdPerMTok: 2 }), - pricing({ offeringId: "o1", inputUsdPerMTok: 1, outputUsdPerMTok: 2 }), - pricing({ offeringId: "o2", inputUsdPerMTok: 1, outputUsdPerMTok: 2 }), - ]); - expect(grouped.get("o1")?.length).toBe(2); - expect(grouped.get("o2")?.length).toBe(1); - }); -}); diff --git a/packages/inference-catalog/src/price.ts b/packages/inference-catalog/src/price.ts deleted file mode 100644 index de1f719acb..0000000000 --- a/packages/inference-catalog/src/price.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Converts decimal-string per-token prices to USD/million. A missing row -// yields `known: false`, never a fabricated zero that wins cheapest-first. -import type { CatalogPricingRow } from "./catalog"; - -export const DEFAULT_CURRENCY = "USD"; - -export type OfferingPrice = { - readonly currency: string; - readonly known: boolean; - readonly inputUsdPerMTok: number | null; - readonly outputUsdPerMTok: number | null; -}; - -const TOKENS_PER_MTOK = 1_000_000; - -/** Converts one per-token decimal string to USD per million tokens. */ -export function perMTok(perToken: string | null | undefined): number | null { - if (perToken === null || perToken === undefined) return null; - const parsed = Number(perToken); - return Number.isFinite(parsed) ? parsed * TOKENS_PER_MTOK : null; -} - -export function groupPricingByOffering( - rows: readonly CatalogPricingRow[], -): Map { - const byOffering = new Map(); - for (const row of rows) { - const existing = byOffering.get(row.offeringId); - if (existing === undefined) byOffering.set(row.offeringId, [row]); - else existing.push(row); - } - return byOffering; -} - -/** The offering's price in `currency`, in USD per million tokens. */ -export function priceForOffering( - rows: readonly CatalogPricingRow[], - currency: string, -): OfferingPrice { - const active = rows.find((row) => row.currency === currency); - if (active === undefined) { - return { - currency, - known: false, - inputUsdPerMTok: null, - outputUsdPerMTok: null, - }; - } - const inputUsdPerMTok = perMTok(active.inputTokenPrice); - const outputUsdPerMTok = perMTok(active.outputTokenPrice); - return { - currency, - known: inputUsdPerMTok !== null && outputUsdPerMTok !== null, - inputUsdPerMTok, - outputUsdPerMTok, - }; -} - -/** What one concept's reference workload would cost at this price, in USD. - * Null whenever the price is not fully known — never a partial estimate. */ -export function referenceCostUsd( - price: OfferingPrice, - mix: { readonly inputMTok: number; readonly outputMTok: number }, -): number | null { - if (!price.known) return null; - if (price.inputUsdPerMTok === null || price.outputUsdPerMTok === null) { - return null; - } - return price.inputUsdPerMTok * mix.inputMTok + price.outputUsdPerMTok * mix.outputMTok; -} - -/** What a run of the given size would cost at this price, in USD. Null - * whenever either axis is unpriced: half an estimate is not an estimate, - * and a zero would read as free. */ -export function estimateUsd( - price: OfferingPrice, - expectedInputTokens: number, - expectedOutputTokens: number, -): number | null { - if (price.inputUsdPerMTok === null || price.outputUsdPerMTok === null) { - return null; - } - return ( - (price.inputUsdPerMTok * expectedInputTokens) / TOKENS_PER_MTOK + - (price.outputUsdPerMTok * expectedOutputTokens) / TOKENS_PER_MTOK - ); -} diff --git a/packages/inference-catalog/src/resolve-chain.test.ts b/packages/inference-catalog/src/resolve-chain.test.ts deleted file mode 100644 index 4223a1a0af..0000000000 --- a/packages/inference-catalog/src/resolve-chain.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ProviderPreference } from "@intx/types"; - -import { offering, pricing } from "./fixtures"; -import { EMPTY_POLICY, type BenchModelPolicy } from "./policy"; -import { - chainToModelRequirements, - resolveModelChain, - UnknownConceptError, - type ResolveChainInput, -} from "./resolve-chain"; - -const CHEAP_LOOP_CAPABLE = ["plain-text", "structured-output"] as const; - -function input(overrides: Partial): ResolveChainInput { - return { - need: { concept: "cheap-loop" }, - offerings: [], - pricing: [], - policy: EMPTY_POLICY, - ...overrides, - }; -} - -function policy(overrides: Partial): BenchModelPolicy { - return { ...EMPTY_POLICY, ...overrides }; -} - -describe("resolveModelChain: the need", () => { - test("a concept nobody shipped is an error that names the real ones", () => { - expect(() => resolveModelChain(input({ need: { concept: "vibes-based" } }))).toThrow( - UnknownConceptError, - ); - try { - resolveModelChain(input({ need: { concept: "vibes-based" } })); - } catch (err) { - expect((err as Error).message).toContain("cheap-loop"); - } - }); - - test("a bare capability ask filters on exactly those capabilities", () => { - const chain = resolveModelChain( - input({ - need: { capabilities: ["vision-input"] }, - offerings: [ - offering({ - id: "o1", - canonicalName: "sees-things", - providerName: "acme", - capabilities: ["vision-input", "plain-text"], - }), - offering({ - id: "o2", - canonicalName: "text-only", - providerName: "acme", - capabilities: ["plain-text"], - }), - ], - }), - ); - expect(chain.concept).toBeNull(); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["o1"]); - expect(chain.excluded).toEqual([{ offeringId: "o2", reason: "missing-capabilities" }]); - }); -}); - -describe("resolveModelChain: what qualifies", () => { - test("a capability the concept requires must be advertised, not merely close", () => { - const chain = resolveModelChain( - input({ - need: { concept: "bulk-extraction" }, - offerings: [ - offering({ - id: "o1", - canonicalName: "half-capable", - providerName: "acme", - capabilities: ["plain-text"], - }), - ], - }), - ); - expect(chain.entries).toEqual([]); - expect(chain.excluded).toEqual([{ offeringId: "o1", reason: "missing-capabilities" }]); - }); -}); - -describe("resolveModelChain: policy selectors", () => { - const offerings = [ - offering({ - id: "o1", - canonicalName: "alpha", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - offering({ - id: "o2", - canonicalName: "beta", - providerName: "globex", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ]; - - test("deny by exact model name", () => { - const chain = resolveModelChain(input({ offerings, policy: policy({ deny: ["alpha"] }) })); - expect(chain.entries.map((entry) => entry.canonicalName)).toEqual(["beta"]); - expect(chain.policyApplied.deny).toBe(true); - }); - - test("deny a whole provider", () => { - const chain = resolveModelChain( - input({ offerings, policy: policy({ deny: ["provider:globex"] }) }), - ); - expect(chain.entries.map((entry) => entry.canonicalName)).toEqual(["alpha"]); - }); - - test("deny one deployment of a model, by provider/model", () => { - const chain = resolveModelChain(input({ offerings, policy: policy({ deny: ["acme/alpha"] }) })); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["o2"]); - }); - - test("a non-empty allow list is the whole world", () => { - const chain = resolveModelChain(input({ offerings, policy: policy({ allow: ["beta"] }) })); - expect(chain.entries.map((entry) => entry.canonicalName)).toEqual(["beta"]); - expect(chain.excluded).toEqual([{ offeringId: "o1", reason: "outside-policy-allow" }]); - }); - - test("an empty allow list constrains nothing", () => { - const chain = resolveModelChain(input({ offerings })); - expect(chain.entries.length).toBe(2); - expect(chain.policyApplied.allow).toBe(false); - }); -}); - -describe("resolveModelChain: price and ordering", () => { - const offerings = [ - offering({ - id: "expensive", - canonicalName: "alpha", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - priority: 0, - }), - offering({ - id: "cheap", - canonicalName: "beta", - providerName: "globex", - capabilities: [...CHEAP_LOOP_CAPABLE], - priority: 10, - }), - ]; - const prices = [ - pricing({ - offeringId: "expensive", - inputUsdPerMTok: 0.2, - outputUsdPerMTok: 0.9, - }), - pricing({ - offeringId: "cheap", - inputUsdPerMTok: 0.05, - outputUsdPerMTok: 0.2, - }), - ]; - - test("cheapest first uses the concept's own token mix", () => { - const chain = resolveModelChain(input({ offerings, pricing: prices })); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["cheap", "expensive"]); - expect(chain.entries[0]?.price.inputUsdPerMTok).toBeCloseTo(0.05, 8); - expect(chain.entries[0]?.referenceCostUsd).toBeCloseTo(0.06, 8); - }); - - test("catalog order puts the catalog's own priority first", () => { - const chain = resolveModelChain(input({ offerings, pricing: prices, order: "catalog" })); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["expensive", "cheap"]); - }); - - test("an unpriced model sorts behind every priced one and is never called free", () => { - const chain = resolveModelChain( - input({ - offerings: [ - ...offerings, - offering({ - id: "unpriced", - canonicalName: "gamma", - providerName: "initech", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ], - pricing: prices, - }), - ); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual([ - "cheap", - "expensive", - "unpriced", - ]); - const unpriced = chain.entries[2]; - expect(unpriced?.price.known).toBe(false); - expect(unpriced?.referenceCostUsd).toBeNull(); - }); - - test("equal cost and priority break ties by name, provider, then id", () => { - const chain = resolveModelChain( - input({ - offerings: [ - offering({ - id: "z", - canonicalName: "same", - providerName: "zulu", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - offering({ - id: "a", - canonicalName: "same", - providerName: "alpha", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - offering({ - id: "m", - canonicalName: "earlier", - providerName: "mike", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ], - pricing: [ - pricing({ - offeringId: "z", - inputUsdPerMTok: 0.1, - outputUsdPerMTok: 0.1, - }), - pricing({ - offeringId: "a", - inputUsdPerMTok: 0.1, - outputUsdPerMTok: 0.1, - }), - pricing({ - offeringId: "m", - inputUsdPerMTok: 0.1, - outputUsdPerMTok: 0.1, - }), - ], - }), - ); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["m", "a", "z"]); - }); -}); - -describe("resolveModelChain: ceilings", () => { - const overCeiling = offering({ - id: "lavish", - canonicalName: "lavish", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }); - const withinCeiling = offering({ - id: "thrifty", - canonicalName: "thrifty", - providerName: "globex", - capabilities: [...CHEAP_LOOP_CAPABLE], - }); - const prices = [ - pricing({ - offeringId: "lavish", - inputUsdPerMTok: 30, - outputUsdPerMTok: 60, - }), - pricing({ - offeringId: "thrifty", - inputUsdPerMTok: 0.1, - outputUsdPerMTok: 0.4, - }), - ]; - - test("a concept ceiling flags and demotes, it never drops", () => { - const chain = resolveModelChain( - input({ offerings: [overCeiling, withinCeiling], pricing: prices }), - ); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["thrifty", "lavish"]); - expect(chain.entries[0]?.overCeiling).toBe(false); - expect(chain.entries[1]?.overCeiling).toBe(true); - }); - - test("a bench with only an over-ceiling model still gets a chain", () => { - const chain = resolveModelChain(input({ offerings: [overCeiling], pricing: prices })); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["lavish"]); - expect(chain.entries[0]?.overCeiling).toBe(true); - }); - - test("a hard bench ceiling excludes and says so", () => { - const chain = resolveModelChain( - input({ - offerings: [overCeiling, withinCeiling], - pricing: prices, - policy: policy({ - maxInputUsdPerMTok: 1, - maxOutputUsdPerMTok: 5, - ceilingIsHard: true, - }), - }), - ); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["thrifty"]); - expect(chain.excluded).toEqual([{ offeringId: "lavish", reason: "over-bench-ceiling" }]); - expect(chain.policyApplied.ceiling).toBe("hard"); - }); - - test("a raised concept ceiling brings a model back within budget", () => { - const chain = resolveModelChain( - input({ - offerings: [overCeiling], - pricing: prices, - policy: policy({ - conceptCeilings: { - "cheap-loop": { maxInputUsdPerMTok: 50, maxOutputUsdPerMTok: 100 }, - }, - }), - }), - ); - expect(chain.entries[0]?.overCeiling).toBe(false); - }); - - test("an unpriced model is never over ceiling — it is simply unknown", () => { - const chain = resolveModelChain( - input({ - offerings: [ - offering({ - id: "mystery", - canonicalName: "mystery", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ], - policy: policy({ maxInputUsdPerMTok: 0.01, ceilingIsHard: true }), - }), - ); - expect(chain.entries.map((entry) => entry.offeringId)).toEqual(["mystery"]); - expect(chain.entries[0]?.overCeiling).toBe(false); - }); -}); - -describe("resolveModelChain: provider preference", () => { - const offerings = [ - offering({ - id: "o1", - canonicalName: "alpha", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - offering({ - id: "o2", - canonicalName: "beta", - providerName: "globex", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ]; - - test("pin keeps only the named providers", () => { - const chain = resolveModelChain( - input({ - offerings, - policy: policy({ - providerPreference: { mode: "pin", order: ["globex"] }, - }), - }), - ); - expect(chain.entries.map((entry) => entry.providerName)).toEqual(["globex"]); - expect(chain.policyApplied.providerPreference).toBe("pin"); - }); - - test("prefer fronts the named providers and keeps the rest as fallback", () => { - const chain = resolveModelChain( - input({ - offerings, - policy: policy({ - providerPreference: { mode: "prefer", order: ["globex"] }, - }), - }), - ); - expect(chain.entries.map((entry) => entry.providerName)).toEqual(["globex", "acme"]); - }); -}); - -describe("resolveModelChain: the chain itself", () => { - test("the last slot goes to another provider when one exists", () => { - const chain = resolveModelChain( - input({ - limit: 2, - offerings: [ - offering({ - id: "a1", - canonicalName: "a1", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - priority: 0, - }), - offering({ - id: "a2", - canonicalName: "a2", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - priority: 1, - }), - offering({ - id: "b1", - canonicalName: "b1", - providerName: "globex", - capabilities: [...CHEAP_LOOP_CAPABLE], - priority: 2, - }), - ], - }), - ); - expect(chain.diversified).toBe(true); - expect(chain.entries.map((entry) => entry.providerName)).toEqual(["acme", "globex"]); - }); - - test("a single-provider bench is not falsely diversified", () => { - const chain = resolveModelChain( - input({ - limit: 2, - offerings: [ - offering({ - id: "a1", - canonicalName: "a1", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - offering({ - id: "a2", - canonicalName: "a2", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - priority: 1, - }), - ], - }), - ); - expect(chain.diversified).toBe(false); - expect(chain.entries.length).toBe(2); - }); - - test("a fresh bench with one connected provider and no policy still answers", () => { - const chain = resolveModelChain({ - need: { concept: "cheap-loop" }, - offerings: [ - offering({ - id: "only", - canonicalName: "only-model", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ], - pricing: [], - policy: EMPTY_POLICY, - }); - expect(chain.entries.map((entry) => entry.canonicalName)).toEqual(["only-model"]); - expect(chain.policyApplied).toEqual({ - allow: false, - deny: false, - ceiling: "none", - providerPreference: "none", - }); - }); - - test("an empty chain comes back with its reasons, never a stand-in model", () => { - const chain = resolveModelChain( - input({ - need: { concept: "image-maker" }, - offerings: [ - offering({ - id: "o1", - canonicalName: "words-only", - providerName: "acme", - capabilities: ["plain-text"], - }), - ], - }), - ); - expect(chain.entries).toEqual([]); - expect(chain.excluded).toEqual([{ offeringId: "o1", reason: "missing-capabilities" }]); - }); -}); - -describe("chainToModelRequirements", () => { - test("one requirement per canonical model, in chain order", () => { - const chain = resolveModelChain( - input({ - offerings: [ - offering({ - id: "o1", - canonicalName: "shared", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - offering({ - id: "o2", - canonicalName: "shared", - providerName: "globex", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - offering({ - id: "o3", - canonicalName: "other", - providerName: "globex", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ], - }), - ); - const requirements = chainToModelRequirements(chain, null); - expect(requirements.map((requirement) => requirement.model)).toEqual(["other", "shared"]); - expect(requirements[0]?.capabilities).toEqual(["plain-text"]); - }); - - test("a pinned provider preference rides along on every requirement", () => { - const chain = resolveModelChain( - input({ - offerings: [ - offering({ - id: "o1", - canonicalName: "alpha", - providerName: "acme", - capabilities: [...CHEAP_LOOP_CAPABLE], - }), - ], - }), - ); - const preference: ProviderPreference = { mode: "pin", order: ["acme"] }; - const requirements = chainToModelRequirements(chain, preference); - expect(requirements[0]?.providers).toEqual(preference); - }); -}); diff --git a/packages/inference-catalog/src/resolve-chain.ts b/packages/inference-catalog/src/resolve-chain.ts deleted file mode 100644 index 9f06ba852b..0000000000 --- a/packages/inference-catalog/src/resolve-chain.ts +++ /dev/null @@ -1,354 +0,0 @@ -// Chain resolution: what this bench can reach for a kind of work, cheapest -// first, with fallbacks. Pure — never touches credentials or a database. -import type { Capability, ProviderPreference } from "@intx/types"; - -import type { CatalogOffering, CatalogPricingRow } from "./catalog"; - -import { - conceptById, - CONCEPT_IDS, - DEFAULT_MIX, - type ConceptCeiling, - type ReferenceMix, -} from "./concepts"; -import { - DEFAULT_CURRENCY, - groupPricingByOffering, - priceForOffering, - referenceCostUsd, - type OfferingPrice, -} from "./price"; -import { EMPTY_POLICY, matchesAny, type BenchModelPolicy } from "./policy"; - -export class UnknownConceptError extends Error { - constructor(readonly concept: string) { - super( - `"${concept}" is not a kind of work this bench knows. Available: ${CONCEPT_IDS.join(", ")}.`, - ); - this.name = "UnknownConceptError"; - } -} - -export type ChainNeed = - | { readonly concept: string } - | { readonly capabilities: readonly Capability[] }; - -export type ChainOrder = "cheapest" | "catalog"; - -export type ResolveChainInput = { - readonly need: ChainNeed; - readonly offerings: readonly CatalogOffering[]; - readonly pricing: readonly CatalogPricingRow[]; - readonly policy: BenchModelPolicy; - readonly currency?: string | undefined; - readonly limit?: number | undefined; - readonly order?: ChainOrder | undefined; -}; - -export type ExclusionReason = - | "missing-capabilities" - | "policy-deny" - | "outside-policy-allow" - | "over-bench-ceiling"; - -export type ChainEntry = { - readonly canonicalName: string; - readonly displayName: string | null; - readonly providerName: string; - readonly plugin: string; - readonly offeringId: string; - readonly priority: number; - readonly capabilities: readonly Capability[]; - readonly price: OfferingPrice; - readonly referenceCostUsd: number | null; - readonly overCeiling: boolean; -}; - -export type ModelChain = { - readonly concept: string | null; - readonly requiredCapabilities: readonly Capability[]; - /** May be empty. An empty chain is returned as-is with its reasons — this - * package never synthesizes an entry to avoid answering "nothing". */ - readonly entries: readonly ChainEntry[]; - readonly excluded: readonly { - readonly offeringId: string; - readonly reason: ExclusionReason; - }[]; - readonly policyApplied: { - readonly allow: boolean; - readonly deny: boolean; - readonly ceiling: "none" | "soft" | "hard"; - readonly providerPreference: "none" | "pin" | "prefer"; - }; - readonly diversified: boolean; -}; - -const DEFAULT_LIMIT = 5; - -type Candidate = { - readonly entry: ChainEntry; - readonly preferScore: number; -}; - -type Need = { - readonly conceptId: string | null; - readonly required: readonly Capability[]; - readonly preferred: readonly Capability[]; - readonly mix: ReferenceMix; - readonly conceptCeiling: ConceptCeiling | null; -}; - -function resolveNeed(need: ChainNeed, policy: BenchModelPolicy): Need { - if (!("concept" in need)) { - return { - conceptId: null, - required: need.capabilities, - preferred: [], - mix: DEFAULT_MIX, - conceptCeiling: null, - }; - } - const concept = conceptById(need.concept); - if (concept === undefined) throw new UnknownConceptError(need.concept); - const override = policy.conceptCeilings[concept.id]; - const conceptCeiling: ConceptCeiling = - override === undefined - ? concept.ceiling - : { - maxInputUsdPerMTok: override.maxInputUsdPerMTok ?? concept.ceiling.maxInputUsdPerMTok, - maxOutputUsdPerMTok: override.maxOutputUsdPerMTok ?? concept.ceiling.maxOutputUsdPerMTok, - }; - return { - conceptId: concept.id, - required: concept.requires, - preferred: concept.prefers, - mix: concept.referenceMix, - conceptCeiling, - }; -} - -function overCeiling( - price: OfferingPrice, - maxInput: number | null, - maxOutput: number | null, -): boolean { - if (!price.known) return false; - if (maxInput !== null && price.inputUsdPerMTok !== null && price.inputUsdPerMTok > maxInput) { - return true; - } - return ( - maxOutput !== null && price.outputUsdPerMTok !== null && price.outputUsdPerMTok > maxOutput - ); -} - -function compareStrings(left: ChainEntry, right: ChainEntry): number { - return ( - left.canonicalName.localeCompare(right.canonicalName) || - left.providerName.localeCompare(right.providerName) || - left.offeringId.localeCompare(right.offeringId) - ); -} - -function compareCandidates(order: ChainOrder) { - return (left: Candidate, right: Candidate): number => { - const leftCost = left.entry.referenceCostUsd; - const rightCost = right.entry.referenceCostUsd; - const byCost = leftCost === null || rightCost === null ? 0 : leftCost - rightCost; - const byPriority = left.entry.priority - right.entry.priority; - const byPreferred = right.preferScore - left.preferScore; - const primary = order === "cheapest" ? byCost || byPriority : byPriority || byCost; - return primary || byPreferred || compareStrings(left.entry, right.entry); - }; -} - -/** `pin` keeps only the named providers; `prefer` fronts them and keeps the - * rest as fallback. The platform applies the same two modes to inference - * sources; its copy is not exported, so this is the same rule stated once - * more against chain entries. */ -function applyProviderPreference( - entries: readonly ChainEntry[], - preference: ProviderPreference | null, -): readonly ChainEntry[] { - if (preference === null) return entries; - const rank = new Map(preference.order.map((name, index) => [name, index])); - if (preference.mode === "pin") { - return entries.filter((entry) => rank.has(entry.providerName)); - } - const named = entries.filter((entry) => rank.has(entry.providerName)); - const rest = entries.filter((entry) => !rank.has(entry.providerName)); - const ordered = [...named].sort( - (left, right) => (rank.get(left.providerName) ?? 0) - (rank.get(right.providerName) ?? 0), - ); - return [...ordered, ...rest]; -} - -export function resolveModelChain(input: ResolveChainInput): ModelChain { - const policy = input.policy; - const currency = input.currency ?? DEFAULT_CURRENCY; - const limit = input.limit ?? DEFAULT_LIMIT; - const order = input.order ?? "cheapest"; - const need = resolveNeed(input.need, policy); - - const pricingByOffering = groupPricingByOffering(input.pricing); - const excluded: { offeringId: string; reason: ExclusionReason }[] = []; - - const within: Candidate[] = []; - const over: Candidate[] = []; - const unpriced: Candidate[] = []; - - for (const resolved of input.offerings) { - const offeringId = resolved.offeringId; - const canonicalName = resolved.canonicalName; - const providerName = resolved.providerName; - - const capabilities = resolved.capabilities; - if (!need.required.every((required) => capabilities.includes(required))) { - excluded.push({ offeringId, reason: "missing-capabilities" }); - continue; - } - - const identity = { canonicalName, providerName }; - if (matchesAny(policy.deny, identity)) { - excluded.push({ offeringId, reason: "policy-deny" }); - continue; - } - if (policy.allow.length > 0 && !matchesAny(policy.allow, identity)) { - excluded.push({ offeringId, reason: "outside-policy-allow" }); - continue; - } - - const price = priceForOffering(pricingByOffering.get(offeringId) ?? [], currency); - const overBenchCeiling = overCeiling( - price, - policy.maxInputUsdPerMTok, - policy.maxOutputUsdPerMTok, - ); - if (overBenchCeiling && policy.ceilingIsHard) { - excluded.push({ offeringId, reason: "over-bench-ceiling" }); - continue; - } - const overConceptCeiling = - need.conceptCeiling !== null && - overCeiling( - price, - need.conceptCeiling.maxInputUsdPerMTok, - need.conceptCeiling.maxOutputUsdPerMTok, - ); - const isOverCeiling = overBenchCeiling || overConceptCeiling; - - const entry: ChainEntry = { - canonicalName, - displayName: resolved.displayName, - providerName, - plugin: resolved.plugin, - offeringId, - priority: resolved.priority, - capabilities, - price, - referenceCostUsd: referenceCostUsd(price, need.mix), - overCeiling: isOverCeiling, - }; - const candidate: Candidate = { - entry, - preferScore: need.preferred.filter((wanted) => capabilities.includes(wanted)).length, - }; - - if (!price.known) unpriced.push(candidate); - else if (isOverCeiling) over.push(candidate); - else within.push(candidate); - } - - const compare = compareCandidates(order); - const unpricedCompare = compareCandidates("catalog"); - const ranked = [ - ...[...within].sort(compare), - ...[...over].sort(compare), - ...[...unpriced].sort(unpricedCompare), - ].map((candidate) => candidate.entry); - - const preferred = applyProviderPreference(ranked, policy.providerPreference); - const capped = preferred.slice(0, limit); - const diversified = shouldDiversify(capped, preferred); - const entries = diversified ? diversify(capped, preferred) : capped; - - return { - concept: need.conceptId, - requiredCapabilities: need.required, - entries, - excluded, - policyApplied: { - allow: policy.allow.length > 0, - deny: policy.deny.length > 0, - ceiling: ceilingMode(policy), - providerPreference: policy.providerPreference?.mode ?? "none", - }, - diversified, - }; -} - -function ceilingMode(policy: BenchModelPolicy): "none" | "soft" | "hard" { - const hasCeiling = policy.maxInputUsdPerMTok !== null || policy.maxOutputUsdPerMTok !== null; - if (!hasCeiling) return "none"; - return policy.ceilingIsHard ? "hard" : "soft"; -} - -/** A chain whose every entry sits behind one provider fails whenever that - * provider does. When the bench has another provider that qualifies, the - * last slot goes to it. */ -function shouldDiversify(capped: readonly ChainEntry[], ranked: readonly ChainEntry[]): boolean { - if (capped.length < 2) return false; - const soleProvider = capped[0]?.providerName; - if (capped.some((entry) => entry.providerName !== soleProvider)) return false; - return ranked.some((entry) => entry.providerName !== soleProvider); -} - -function diversify( - capped: readonly ChainEntry[], - ranked: readonly ChainEntry[], -): readonly ChainEntry[] { - const soleProvider = capped[0]?.providerName; - const alternative = ranked.find((entry) => entry.providerName !== soleProvider); - if (alternative === undefined) return capped; - return [...capped.slice(0, capped.length - 1), alternative]; -} - -/** - * The hand-off to the platform: one requirement per canonical model, in - * chain order. `ModelRequirements` rejects a repeated model, so the chain's - * cross-provider deployments of one model collapse to a single requirement — - * source resolution expands them back out to failover sources itself. - */ -export function chainToModelRequirements( - chain: ModelChain, - providers: ProviderPreference | null, -): readonly { - model: string; - capabilities: Capability[]; - providers?: ProviderPreference; -}[] { - const seen = new Set(); - const requirements: { - model: string; - capabilities: Capability[]; - providers?: ProviderPreference; - }[] = []; - for (const entry of chain.entries) { - if (seen.has(entry.canonicalName)) continue; - seen.add(entry.canonicalName); - requirements.push( - providers === null - ? { - model: entry.canonicalName, - capabilities: [...chain.requiredCapabilities], - } - : { - model: entry.canonicalName, - capabilities: [...chain.requiredCapabilities], - providers, - }, - ); - } - return requirements; -} - -export { EMPTY_POLICY }; diff --git a/packages/inference-catalog/tsconfig.json b/packages/inference-catalog/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/packages/inference-catalog/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/packages/inference-catalog/tsconfig.src.json b/packages/inference-catalog/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/packages/inference-catalog/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/scripts/run-all.ts b/scripts/run-all.ts index d403ae1ac5..ab4f595c3f 100644 --- a/scripts/run-all.ts +++ b/scripts/run-all.ts @@ -42,7 +42,6 @@ export function resolveConcurrency( const WORKSPACE_ROOTS = [ "apps/*/package.json", "packages/*/package.json", - "tools/*/package.json", "agents/*/package.json", "skills/*/package.json", "workflows/*/package.json", diff --git a/scripts/typecheck.ts b/scripts/typecheck.ts index e8875b2f13..92888014a2 100644 --- a/scripts/typecheck.ts +++ b/scripts/typecheck.ts @@ -32,7 +32,6 @@ import { resolveConcurrency } from "./concurrency.ts"; const PROJECT_GLOBS = [ "apps/*/tsconfig.json", "packages/*/tsconfig.json", - "tools/*/tsconfig.json", "workflows/*/tsconfig.json", "vendor/intx/*/tsconfig.json", ]; diff --git a/tools/access/LICENSE b/tools/access/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/access/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/access/README.md b/tools/access/README.md deleted file mode 100644 index 4e90f1938b..0000000000 --- a/tools/access/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# @corbits/access-tools - -Myra's principal and grant tools — a thin wrapper over the tenant's real -principal and grant rows, gated by Interchange's own grant-store -authorization (`@intx/authz`), never a reimplementation of it. Lets an -agent see who exists in its tenant and, once a human approves, grant or -revoke scoped access for the specialist agents it stands up. - -`list_principals` lists every principal (user, agent, or workflow run) -in the calling agent's tenant, with its id, kind, and status, so a grant -can target the right principal id. - -`list_grants` lists grants in the tenant, optionally filtered by -`principalId` or `resource`, so an agent can see what a principal -already has before granting more, or find the grant id `revoke_access` -needs. - -`grant_access` grants a principal one or more actions on a resource -string (e.g. `read` on `workflow-run:*`). Declared `approval: "ask"` — -a human must approve the specific principal/resource/actions triple -before anything is written. - -`revoke_access` revokes a previously created grant by its id. Also -declared `approval: "ask"`. - -See `./src/routes.ts` for the workflow-run-authenticated hub-side -routes these tools call, mounted at `/api/workflow-access`. diff --git a/tools/access/package.json b/tools/access/package.json deleted file mode 100644 index af94487987..0000000000 --- a/tools/access/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@corbits/access-tools", - "version": "0.0.6", - "description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, calling stock Interchange tenant routes with the workflow run's own bearer credential and enforcing a delegation ceiling before it grants", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/access" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@corbits/error-sink": "0.0.2", - "@intx/agent": "0.3.0", - "@intx/authz": "0.3.0", - "@intx/types": "0.3.0" - } -} diff --git a/tools/access/src/client.test.ts b/tools/access/src/client.test.ts deleted file mode 100644 index 8c3b1c9aa0..0000000000 --- a/tools/access/src/client.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - AccessForbiddenError, - AccessNotFoundError, - DelegationCeilingError, - grantAccess, - listGrants, - listPrincipals, - revokeAccess, - type AccessToolClientConfig, -} from "./client"; - -const TENANT_BASE = "https://hub.example.com/api/tenants/ten_1"; - -function testConfig(fetchImpl: typeof fetch): AccessToolClientConfig { - return { - hubAccessUrl: "https://hub.example.com", - tenantId: "ten_1", - principalId: "prin_caller", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - }; -} - -function nativePrincipal(id: string) { - return { - id, - kind: "workflow", - refId: `${id}@example.test`, - status: "active", - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", - }; -} - -function nativeGrant(id: string, overrides: Record = {}) { - return { - id, - principalId: "prin_2", - resource: "workflow-run:*", - action: "read", - effect: "allow", - origin: "invoker", - conditions: null, - expiresAt: null, - roleId: null, - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", - ...overrides, - }; -} - -/** The caller's own ceiling: whatever `firstActionOutsideCeiling` reads back - * for `principalId=prin_caller`. */ -function ceilingPage(grants: readonly unknown[]) { - return Response.json({ data: grants, nextCursor: null }); -} - -test("listPrincipals reads the stock page envelope and follows nextCursor", async () => { - const seen: string[] = []; - const fetchImpl = (async (url: string | URL | Request) => { - const raw = String(url); - seen.push(raw); - if (raw.includes("cursor=prin_2")) { - return Response.json({ - data: [nativePrincipal("prin_3")], - nextCursor: null, - }); - } - return Response.json({ - data: [nativePrincipal("prin_1"), nativePrincipal("prin_2")], - nextCursor: "prin_2", - }); - }) as unknown as typeof fetch; - - const principals = await listPrincipals(testConfig(fetchImpl)); - expect(principals.map((p) => p.id)).toEqual(["prin_1", "prin_2", "prin_3"]); - expect(seen[0]).toStartWith(`${TENANT_BASE}/principals?`); - expect(seen).toHaveLength(2); - expect(seen[1]).toContain("cursor=prin_2"); -}); - -test("listGrants passes filters through and reads the stock page envelope", async () => { - let seenUrl = ""; - const fetchImpl = (async (url: string | URL | Request) => { - seenUrl = String(url); - return Response.json({ data: [nativeGrant("grant_1")], nextCursor: null }); - }) as unknown as typeof fetch; - - const grants = await listGrants(testConfig(fetchImpl), { - principalId: "prin_2", - resource: "workflow-run:*", - }); - expect(seenUrl).toContain(`${TENANT_BASE}/grants?`); - expect(seenUrl).toContain("principalId=prin_2"); - expect(seenUrl).toContain("resource=workflow-run%3A*"); - expect(grants).toHaveLength(1); - expect(grants[0]).toMatchObject({ - id: "grant_1", - principalId: "prin_2", - resource: "workflow-run:*", - action: "read", - effect: "allow", - }); -}); - -test("listGrants follows nextCursor so a full page plus one all return", async () => { - const all = Array.from({ length: 51 }, (_, i) => nativeGrant(`grant_${i + 1}`)); - const seen: string[] = []; - const fetchImpl = (async (url: string | URL | Request) => { - const raw = String(url); - seen.push(raw); - if (raw.includes("cursor=page_2")) { - return Response.json({ data: all.slice(50), nextCursor: null }); - } - return Response.json({ data: all.slice(0, 50), nextCursor: "page_2" }); - }) as unknown as typeof fetch; - - const grants = await listGrants(testConfig(fetchImpl), { - principalId: "prin_2", - }); - expect(grants).toHaveLength(51); - expect(seen).toHaveLength(2); - expect(seen[1]).toContain("cursor=page_2"); -}); - -test("grantAccess reads the caller's own grants first, then posts one stock single-action body per action", async () => { - const posted: unknown[] = []; - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - if (init?.method !== "POST") { - expect(String(url)).toContain("principalId=prin_caller"); - return ceilingPage([nativeGrant("own_1", { principalId: "prin_caller", action: "*" })]); - } - expect(String(url)).toBe(`${TENANT_BASE}/grants`); - const body = JSON.parse(String(init.body)) as Record; - posted.push(body); - return new Response( - JSON.stringify({ - ...nativeGrant(`grant_${posted.length}`), - action: body["action"], - }), - { status: 201 }, - ); - }) as unknown as typeof fetch; - - const grants = await grantAccess(testConfig(fetchImpl), { - principalId: "prin_2", - resource: "workflow-run:*", - actions: ["read", "write"], - }); - expect(posted).toEqual([ - { - principalId: "prin_2", - resource: "workflow-run:*", - action: "read", - effect: "allow", - origin: "invoker", - }, - { - principalId: "prin_2", - resource: "workflow-run:*", - action: "write", - effect: "allow", - origin: "invoker", - }, - ]); - expect(grants.map((g) => g.action)).toEqual(["read", "write"]); -}); - -test("grantAccess refuses a pair outside the caller's own authority, before any write", async () => { - const methods: (string | undefined)[] = []; - const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => { - methods.push(init?.method); - return ceilingPage([nativeGrant("own_1", { principalId: "prin_caller", action: "read" })]); - }) as unknown as typeof fetch; - - await expect( - grantAccess(testConfig(fetchImpl), { - principalId: "prin_2", - resource: "workflow-run:*", - actions: ["read", "write"], - }), - ).rejects.toThrow(DelegationCeilingError); - expect(methods).toEqual([undefined]); -}); - -test("a 403 from the stock route surfaces as AccessForbiddenError with the hub's message", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { code: "forbidden", message: "Not a member of this tenant" }, - }), - { status: 403 }, - )) as unknown as typeof fetch; - - await expect( - grantAccess(testConfig(fetchImpl), { - principalId: "prin_2", - resource: "workflow-run:*", - actions: ["read"], - }), - ).rejects.toThrow(AccessForbiddenError); -}); - -test("a 404 looking up the grant to revoke surfaces as AccessNotFoundError", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { code: "not_found", message: "no such grant" }, - }), - { status: 404 }, - )) as unknown as typeof fetch; - - await expect(revokeAccess(testConfig(fetchImpl), "grant_missing")).rejects.toThrow( - AccessNotFoundError, - ); -}); - -test("revokeAccess checks the ceiling against the grant it read back, then deletes", async () => { - const calls: string[] = []; - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - calls.push(`${init?.method ?? "GET"} ${String(url)}`); - if (init?.method === "DELETE") return new Response(null, { status: 204 }); - if (String(url).includes("principalId=prin_caller")) { - return ceilingPage([nativeGrant("own_1", { principalId: "prin_caller", action: "*" })]); - } - return Response.json(nativeGrant("grant_1")); - }) as unknown as typeof fetch; - - await revokeAccess(testConfig(fetchImpl), "grant_1"); - expect(calls[0]).toBe(`GET ${TENANT_BASE}/grants/grant_1`); - expect(calls.at(-1)).toBe(`DELETE ${TENANT_BASE}/grants/grant_1`); -}); - -test("revokeAccess refuses a grant outside the caller's own authority", async () => { - const methods: (string | undefined)[] = []; - const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { - methods.push(init?.method); - if (String(url).includes("principalId=prin_caller")) { - return ceilingPage([]); - } - return Response.json(nativeGrant("grant_1")); - }) as unknown as typeof fetch; - - await expect(revokeAccess(testConfig(fetchImpl), "grant_1")).rejects.toThrow( - DelegationCeilingError, - ); - expect(methods).not.toContain("DELETE"); -}); - -test("a grant body missing principalId is a shape error, never a null-linked grant", async () => { - const { principalId: _link, ...linkless } = nativeGrant("grant_1"); - void _link; - const fetchImpl = (async () => - Response.json({ - data: [linkless], - nextCursor: null, - })) as unknown as typeof fetch; - - await expect(listGrants(testConfig(fetchImpl), {})).rejects.toThrow( - "did not match the expected shape", - ); -}); diff --git a/tools/access/src/client.ts b/tools/access/src/client.ts deleted file mode 100644 index 585fc795c4..0000000000 --- a/tools/access/src/client.ts +++ /dev/null @@ -1,336 +0,0 @@ -// The tenant id rides in the client config as a path segment only, never -// trusted as authority (the hub sets the acting tenant from the -// authenticated run alone). Grants are not inherited across the tenant -// ancestor tree, so every read/write here is the run's own tenant only. -import { type } from "arktype"; -import { evaluateGrants, type GrantRule } from "@intx/authz"; - -export interface AccessToolClientConfig { - /** The hub's plain HTTP origin. */ - readonly hubAccessUrl: string; - /** The run's own tenant — the `:tenantId` segment of every stock route. */ - readonly tenantId: string; - /** The run's own principal, whose authority bounds every grant it makes. */ - readonly principalId: string; - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; -} - -export interface ListedPrincipal { - readonly id: string; - readonly kind: "user" | "agent" | "workflow"; - readonly refId: string; - readonly status: "active" | "suspended" | "invited" | "deactivated"; -} - -export interface ListedGrant { - readonly id: string; - readonly principalId: string | null; - readonly resource: string; - readonly action: string; - readonly effect: "allow" | "deny" | "ask"; -} - -export interface GrantAccessRequest { - readonly principalId: string; - readonly resource: string; - readonly actions: readonly string[]; -} - -function authHeaders(config: AccessToolClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -function tenantBase(config: AccessToolClientConfig): string { - return `${config.hubAccessUrl}/api/tenants/${encodeURIComponent(config.tenantId)}`; -} - -/** Pulls a message out of either error envelope the hub emits: the - * canonical `{error: {code, userMessage, refId}}` and the stock - * `{error: {code, message}}` shape the vendored routes use. */ -function errorMessageFrom(body: unknown): string | undefined { - if (body === null || typeof body !== "object" || !("error" in body)) { - return undefined; - } - const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object") return undefined; - for (const key of ["userMessage", "message"] as const) { - if (key in error) { - const value = (error as Record)[key]; - if (typeof value === "string") return value; - } - } - return undefined; -} - -async function readErrorMessage(response: Response, fallback: string): Promise { - const body: unknown = await response.json().catch(() => undefined); - return errorMessageFrom(body) ?? fallback; -} - -/** Thrown when the hub rejects the request as forbidden — the caller's own - * principal holds no `principal:*`/`grant:*` grant — distinct from a bare - * transport/HTTP failure, so a caller can report honestly that a human must - * grant it access first. */ -export class AccessForbiddenError extends Error {} - -/** Thrown for a not-found principal/grant id, distinct from a bare - * transport/HTTP failure. */ -export class AccessNotFoundError extends Error {} - -async function doRequest( - config: AccessToolClientConfig, - path: string, - init: RequestInit, - failureLabel: string, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch(`${tenantBase(config)}${path}`, { - ...init, - headers: { ...authHeaders(config), ...(init.headers ?? {}) }, - }); - if (response.status === 403) { - throw new AccessForbiddenError(await readErrorMessage(response, `${failureLabel}: forbidden`)); - } - if (response.status === 404) { - throw new AccessNotFoundError(await readErrorMessage(response, `${failureLabel}: not found`)); - } - if (!response.ok) { - throw new Error(`${failureLabel}: ${response.status} ${response.statusText}`); - } - return response; -} - -const PAGE_LIMIT = 100; - -const PrincipalsPage = type({ - data: type({ - id: "string", - kind: "'user'|'agent'|'workflow'", - refId: "string", - status: "'active'|'suspended'|'invited'|'deactivated'", - }).array(), - "nextCursor?": "string | null", -}); - -const GrantsPage = type({ - data: type({ - id: "string", - principalId: "string | null", - resource: "string", - action: "string", - effect: "'allow'|'deny'|'ask'", - origin: "'system'|'role'|'creator'|'invoker'", - "roleId?": "string | null", - "conditions?": "Record | null", - "expiresAt?": "string | null", - }).array(), - "nextCursor?": "string | null", -}); - -const CreatedGrant = type({ - id: "string", - principalId: "string | null", - resource: "string", - action: "string", - effect: "'allow'|'deny'|'ask'", -}); - -type GrantRow = (typeof GrantsPage.infer)["data"][number]; - -/** Walks every page of a stock paginated collection. The tool surfaces a - * tenant's whole principal or grant list, so a truncated first page would - * read as "that principal has no grants" — a wrong answer, not a slow one. */ -async function readAllPages( - config: AccessToolClientConfig, - path: string, - query: URLSearchParams, - failureLabel: string, - parse: ( - body: unknown, - ) => { data: readonly T[]; nextCursor?: string | null | undefined } | type.errors, -): Promise { - const items: T[] = []; - let cursor: string | undefined; - for (;;) { - const params = new URLSearchParams(query); - params.set("limit", String(PAGE_LIMIT)); - if (cursor !== undefined) params.set("cursor", cursor); - const response = await doRequest(config, `${path}?${params.toString()}`, {}, failureLabel); - const parsed = parse(await response.json()); - if (parsed instanceof type.errors) { - throw new Error( - `${failureLabel}: response did not match the expected shape: ${parsed.summary}`, - ); - } - items.push(...parsed.data); - const next = parsed.nextCursor; - if (next === undefined || next === null) return items; - cursor = next; - } -} - -export async function listPrincipals( - config: AccessToolClientConfig, -): Promise { - return await readAllPages( - config, - "/principals", - new URLSearchParams(), - "Listing principals failed", - (body) => PrincipalsPage(body), - ); -} - -export interface ListGrantsFilter { - readonly principalId?: string; - readonly resource?: string; -} - -async function listGrantRows( - config: AccessToolClientConfig, - filter?: ListGrantsFilter, -): Promise { - const params = new URLSearchParams(); - if (filter?.principalId !== undefined) { - params.set("principalId", filter.principalId); - } - if (filter?.resource !== undefined) params.set("resource", filter.resource); - return await readAllPages(config, "/grants", params, "Listing grants failed", (body) => - GrantsPage(body), - ); -} - -export async function listGrants( - config: AccessToolClientConfig, - filter?: ListGrantsFilter, -): Promise { - const rows = await listGrantRows(config, filter); - return rows.map((row) => ({ - id: row.id, - principalId: row.principalId, - resource: row.resource, - action: row.action, - effect: row.effect, - })); -} - -function toGrantRule(row: GrantRow): GrantRule { - return { - id: row.id, - resource: row.resource, - action: row.action, - effect: row.effect, - origin: row.origin, - conditions: row.conditions ?? null, - expiresAt: - row.expiresAt === undefined || row.expiresAt === null ? null : new Date(row.expiresAt), - roleId: row.roleId ?? null, - principalId: row.principalId, - }; -} - -/** Thrown when the caller asks to grant or revoke authority it does not - * itself hold. */ -export class DelegationCeilingError extends Error {} - -/** Enforces client-side that a caller may only grant/revoke authority it already holds, since stock POST /grants has no such check. */ -export async function firstActionOutsideCeiling( - config: AccessToolClientConfig, - resource: string, - actions: readonly string[], -): Promise { - const own = await listGrantRows(config, { - principalId: config.principalId, - }); - const rules = own.map(toGrantRule); - for (const action of actions) { - const result = await evaluateGrants(rules, resource, action, { - principalId: config.principalId, - tenantId: config.tenantId, - }); - if (result.effect !== "allow") return action; - } - return null; -} - -export async function grantAccess( - config: AccessToolClientConfig, - input: GrantAccessRequest, -): Promise { - const outside = await firstActionOutsideCeiling(config, input.resource, input.actions); - if (outside !== null) { - throw new DelegationCeilingError( - `Cannot grant "${input.resource}" "${outside}": exceeds the caller's own authority`, - ); - } - - // Stock `POST /grants` creates one resource/action pair per call, so a - // multi-action request is one call per action. A failure part-way leaves - // the already-created grants in place and surfaces loudly rather than - // rolling back behind the caller's back. - const created: ListedGrant[] = []; - for (const action of input.actions) { - const response = await doRequest( - config, - "/grants", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - principalId: input.principalId, - resource: input.resource, - action, - effect: "allow", - // The invoking human's authority delegated this grant: the tool - // declares `approval: "ask"`, so a human approved this exact - // principal/resource/actions triple before this call ran. - origin: "invoker", - }), - }, - "Granting access failed", - ); - const parsed = CreatedGrant(await response.json()); - if (parsed instanceof type.errors) { - throw new Error(`Grant-access response did not match the expected shape: ${parsed.summary}`); - } - created.push({ - id: parsed.id, - principalId: parsed.principalId, - resource: parsed.resource, - action: parsed.action, - effect: parsed.effect, - }); - } - return created; -} - -export async function revokeAccess(config: AccessToolClientConfig, grantId: string): Promise { - const response = await doRequest( - config, - `/grants/${encodeURIComponent(grantId)}`, - {}, - "Revoking access failed", - ); - const target = CreatedGrant(await response.json()); - if (target instanceof type.errors) { - throw new Error(`Grant lookup response did not match the expected shape: ${target.summary}`); - } - const outside = await firstActionOutsideCeiling(config, target.resource, [target.action]); - if (outside !== null) { - throw new DelegationCeilingError( - `Cannot revoke "${target.resource}" "${target.action}": exceeds the caller's own authority`, - ); - } - await doRequest( - config, - `/grants/${encodeURIComponent(grantId)}`, - { method: "DELETE" }, - "Revoking access failed", - ); -} diff --git a/tools/access/src/index.ts b/tools/access/src/index.ts deleted file mode 100644 index 7c4ea44ae9..0000000000 --- a/tools/access/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { - grantAccess, - listGrants, - listPrincipals, - revokeAccess, - AccessForbiddenError, - AccessNotFoundError, - DelegationCeilingError, - firstActionOutsideCeiling, - type AccessToolClientConfig, - type GrantAccessRequest, - type ListedGrant, - type ListedPrincipal, - type ListGrantsFilter, -} from "./client"; -export { - accessTools, - GRANT_ACCESS_TOOL, - LIST_GRANTS_TOOL, - LIST_PRINCIPALS_TOOL, - REVOKE_ACCESS_TOOL, - type WorkflowAccessEnv, -} from "./tool"; diff --git a/tools/access/src/tool.test.ts b/tools/access/src/tool.test.ts deleted file mode 100644 index a52538d142..0000000000 --- a/tools/access/src/tool.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { accessTools, GRANT_ACCESS_TOOL, LIST_GRANTS_TOOL, type WorkflowAccessEnv } from "./tool"; - -function testEnv(): WorkflowAccessEnv { - return { - hubAccessUrl: "https://hub.example.com", - tenantId: "ten_1", - principalId: "prin_caller", - sidecarToken: "sc-token", - address: "run_1@workflow", - } as unknown as WorkflowAccessEnv; -} - -function callFor(name: string, args: Record): ToolCall { - return { id: "call_1", name, arguments: args }; -} - -test("list_grants rejects invalid input before ever calling fetch", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { - throw new Error("fetch should not be called"); - }) as unknown as typeof fetch; - try { - const bundle = accessTools(testEnv()); - const result = await bundle.run( - callFor(LIST_GRANTS_TOOL, { principalId: "" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/invalid input/i); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("a 403 from the stock grant route surfaces as a clear tool error", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - error: { - code: "forbidden", - message: "Myra can only grant room:read — she herself holds no room:write here", - }, - }), - { status: 403 }, - )) as unknown as typeof fetch; - try { - const bundle = accessTools(testEnv()); - const result = await bundle.run( - callFor(GRANT_ACCESS_TOOL, { - principalId: "prin_2", - resource: "room:*", - actions: ["write"], - }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toBe( - "Myra can only grant room:read — she herself holds no room:write here", - ); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("a successful grant_access round-trips into list_grants", async () => { - const originalFetch = globalThis.fetch; - function nativeRow(id: string, action: string) { - return { - id, - principalId: "prin_2", - resource: "workflow-run:*", - action, - effect: "allow", - origin: "invoker", - conditions: null, - expiresAt: null, - roleId: null, - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", - }; - } - const posted: unknown[] = []; - globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { - const path = new URL(String(url)).pathname; - if (init?.method === "POST" && path.endsWith("/grants")) { - const body = JSON.parse(String(init?.body)) as { action: string }; - posted.push(body); - // Native `POST /grants` creates exactly one single-action grant and - // returns the single `GrantResponse` object. - return new Response(JSON.stringify(nativeRow(`grant_${posted.length}`, body.action)), { - status: 201, - }); - } - if (path.endsWith("/grants")) { - // The caller's own ceiling read: wide enough to delegate anything. - if (String(url).includes("principalId=prin_caller")) { - return new Response( - JSON.stringify({ - data: [{ ...nativeRow("own_1", "*"), principalId: "prin_caller" }], - nextCursor: null, - }), - ); - } - // Stock `GET /grants` returns the `{data, nextCursor}` page envelope. - return new Response( - JSON.stringify({ - data: - posted.length === 0 - ? [] - : posted.map((body, index) => - nativeRow(`grant_${index + 1}`, (body as { action: string }).action), - ), - nextCursor: null, - }), - ); - } - throw new Error(`unexpected request: ${String(url)}`); - }) as unknown as typeof fetch; - - try { - const bundle = accessTools(testEnv()); - const grantResult = await bundle.run( - callFor(GRANT_ACCESS_TOOL, { - principalId: "prin_2", - resource: "workflow-run:*", - actions: ["read", "write"], - }), - new AbortController().signal, - ); - expect(grantResult.isError).toBe(false); - expect(grantResult.content).toContain("2 grants created"); - - const listResult = await bundle.run( - callFor(LIST_GRANTS_TOOL, { principalId: "prin_2" }), - new AbortController().signal, - ); - expect(listResult.isError).toBe(false); - expect(listResult.content).toContain("grant_1"); - expect(listResult.content).toContain("workflow-run:*"); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tools/access/src/tool.ts b/tools/access/src/tool.ts deleted file mode 100644 index 55bf3bac17..0000000000 --- a/tools/access/src/tool.ts +++ /dev/null @@ -1,273 +0,0 @@ -// grant_access/revoke_access carry approval: "ask" (like request_capability), -// letting Myra create scoped grants for specialist agents gated behind a -// human approving each one. See ./client.ts for the delegation ceiling -// enforced before anything is granted. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { type } from "arktype"; -import { reportError } from "@corbits/error-sink"; - -import { - grantAccess, - listGrants, - listPrincipals, - revokeAccess, - type AccessToolClientConfig, -} from "./client"; - -export const LIST_PRINCIPALS_TOOL = "list_principals"; -export const LIST_GRANTS_TOOL = "list_grants"; -export const GRANT_ACCESS_TOOL = "grant_access"; -export const REVOKE_ACCESS_TOOL = "revoke_access"; - -export interface WorkflowAccessEnv extends BaseEnv { - readonly hubAccessUrl: string; - readonly sidecarToken: string; - readonly address: string; - readonly tenantId: string; - readonly principalId: string; -} - -const ListGrantsInput = type({ - "principalId?": "string > 0", - "resource?": "string > 0", -}); - -const GrantAccessInput = type({ - principalId: "string > 0", - resource: "string > 0", - actions: type("string > 0").array().atLeastLength(1), - "why?": "string > 0", -}); - -const RevokeAccessInput = type({ - grantId: "string > 0", -}); - -function errorResult(callId: string, err: unknown): ToolResult { - return { - callId, - isError: true, - content: err instanceof Error ? err.message : String(err), - }; -} - -function clientConfig(env: WorkflowAccessEnv): AccessToolClientConfig { - return { - hubAccessUrl: env.hubAccessUrl, - sidecarToken: env.sidecarToken, - address: env.address, - tenantId: env.tenantId, - principalId: env.principalId, - }; -} - -async function runListPrincipals(env: WorkflowAccessEnv, call: ToolCall): Promise { - try { - const principals = await listPrincipals(clientConfig(env)); - const content = - principals.length === 0 - ? "No principals in this tenant." - : principals.map((p) => `${p.kind} ${p.refId} — ${p.status} (id: ${p.id})`).join("\n"); - return { callId: call.id, isError: false, content }; - } catch (err) { - reportError(err, { - operation: "list_principals", - agentId: env.address, - }); - return errorResult(call.id, err); - } -} - -async function runListGrants(env: WorkflowAccessEnv, call: ToolCall): Promise { - const parsed = ListGrantsInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult(call.id, new Error(`list_grants received invalid input: ${parsed.summary}`)); - } - try { - const grants = await listGrants(clientConfig(env), { - ...(parsed.principalId !== undefined ? { principalId: parsed.principalId } : {}), - ...(parsed.resource !== undefined ? { resource: parsed.resource } : {}), - }); - const content = - grants.length === 0 - ? "No matching grants." - : grants - .map( - (g) => - `${g.effect} ${g.resource}:${g.action} -> principal ${g.principalId ?? "(role)"} (id: ${g.id})`, - ) - .join("\n"); - return { callId: call.id, isError: false, content }; - } catch (err) { - reportError(err, { operation: "list_grants", agentId: env.address }); - return errorResult(call.id, err); - } -} - -async function runGrantAccess(env: WorkflowAccessEnv, call: ToolCall): Promise { - const parsed = GrantAccessInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`grant_access received invalid input: ${parsed.summary}`), - ); - } - try { - const grants = await grantAccess(clientConfig(env), { - principalId: parsed.principalId, - resource: parsed.resource, - actions: parsed.actions, - }); - return { - callId: call.id, - isError: false, - content: `Granted ${parsed.actions.join(", ")} on ${parsed.resource} to ${parsed.principalId} (${grants.length} grant${grants.length === 1 ? "" : "s"} created).`, - }; - } catch (err) { - reportError(err, { operation: "grant_access", agentId: env.address }); - return errorResult(call.id, err); - } -} - -async function runRevokeAccess(env: WorkflowAccessEnv, call: ToolCall): Promise { - const parsed = RevokeAccessInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`revoke_access received invalid input: ${parsed.summary}`), - ); - } - try { - await revokeAccess(clientConfig(env), parsed.grantId); - return { - callId: call.id, - isError: false, - content: `Revoked grant ${parsed.grantId}.`, - }; - } catch (err) { - reportError(err, { operation: "revoke_access", agentId: env.address }); - return errorResult(call.id, err); - } -} - -/** - * The `@corbits/access-tools` bundle factory: `list_principals` and - * `list_grants` (read, no approval), `grant_access` and `revoke_access` - * (`approval: "ask"`) — Myra's own way to create principals-scoped - * grants for the agents she stands up. - */ -export const accessTools = defineTool({ - id: "@corbits/access-tools/access", - requires: ["hubAccessUrl", "sidecarToken", "address", "tenantId", "principalId"], - definitions: [ - { name: LIST_PRINCIPALS_TOOL }, - { name: LIST_GRANTS_TOOL }, - { name: GRANT_ACCESS_TOOL, approval: "ask" }, - { name: REVOKE_ACCESS_TOOL, approval: "ask" }, - ], - factory: (env) => ({ - definitions: [ - { - name: LIST_PRINCIPALS_TOOL, - description: - "List the principals (users, agents, and workflow runs) in " + - "this tenant, with their id, kind, and status — use this to " + - "find the principal id a grant should target.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: LIST_GRANTS_TOOL, - description: - "List grants in this tenant, optionally filtered by " + - "principalId or resource — use this to see what a principal " + - "already has before granting more, or to find the grant id " + - "revoke_access needs.", - inputSchema: { - type: "object", - properties: { - principalId: { - type: "string", - description: "Only grants targeting this principal id.", - }, - resource: { - type: "string", - description: 'Only grants for this exact resource string (e.g. "workflow-run:*").', - }, - }, - }, - }, - { - name: GRANT_ACCESS_TOOL, - description: - "grant a principal one or more scoped actions on a resource " + - "-- e.g. letting a specialist agent read its own workflow " + - "runs. A human must approve before anything is granted. Use " + - "this only for a genuine, specific need; never speculatively, " + - "and never grant broader access than the specific need " + - "requires.", - inputSchema: { - type: "object", - properties: { - principalId: { - type: "string", - description: "The principal id to grant access to (from list_principals).", - }, - resource: { - type: "string", - description: - 'The resource string to grant, e.g. "workflow-run:*" or ' + '"asset:ast_123".', - }, - actions: { - type: "array", - items: { type: "string" }, - description: - 'Actions to grant on that resource, e.g. ["read"] or ' + '["read", "write"].', - }, - why: { - type: "string", - description: - "One sentence on why this grant is needed right now, " + - "shown to the person approving the request.", - }, - }, - required: ["principalId", "resource", "actions"], - }, - }, - { - name: REVOKE_ACCESS_TOOL, - description: - "Revoke a previously created grant by its id (from " + - "list_grants). A human must approve before anything is " + - "revoked.", - inputSchema: { - type: "object", - properties: { - grantId: { - type: "string", - description: "The grant id to revoke.", - }, - }, - required: ["grantId"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - switch (call.name) { - case LIST_PRINCIPALS_TOOL: - return runListPrincipals(env, call); - case LIST_GRANTS_TOOL: - return runListGrants(env, call); - case GRANT_ACCESS_TOOL: - return runGrantAccess(env, call); - case REVOKE_ACCESS_TOOL: - return runRevokeAccess(env, call); - default: - return Promise.resolve( - errorResult(call.id, new Error(`@corbits/access-tools: unknown tool "${call.name}"`)), - ); - } - }, - }), -}); diff --git a/tools/access/tsconfig.json b/tools/access/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/access/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/access/tsconfig.src.json b/tools/access/tsconfig.src.json deleted file mode 100644 index d84257c8f8..0000000000 --- a/tools/access/tsconfig.src.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true, - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/agent-directory/LICENSE b/tools/agent-directory/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/agent-directory/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/agent-directory/README.md b/tools/agent-directory/README.md deleted file mode 100644 index e71ba34c6c..0000000000 --- a/tools/agent-directory/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# @corbits/agent-directory-tools - -Myra's manager tool bundle: `list_agents`, `create_agent`, and -`message_agent`, as an `@intx/agent` tool factory. - -Every call reaches stock Interchange routes with the workflow run's own -bearer credential — no workbench-specific mount: - -- `list_agents` reads `GET /api/tenants/:t/assets?kind=workflow`, keeping - the `agent--source` assets a created agent's deploy names, and - derives each one's live mail address from its slug and the tenant's - domain. -- `create_agent` renders a single-step, mail-triggered workflow - definition, ensures its source asset, pushes it over the asset's git - smart-HTTP remote, resolves the tenant's own catalog offerings, and - deploys through `POST /api/tenants/:t/workflows/deployments` — the same - pipeline the web app's hand-authored create-agent panel runs. -- `message_agent` sends a mail-shaped message to another agent's address - through the stock mailbox, `POST /api/tenants/:t/mailbox/me/inbox/send`. - -Creation is free (no approval key): a new definition plus its deploy, not -a capability grant. diff --git a/tools/agent-directory/package.json b/tools/agent-directory/package.json deleted file mode 100644 index c5e5d0d27a..0000000000 --- a/tools/agent-directory/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@corbits/agent-directory-tools", - "version": "0.0.7", - "description": "Myra's manager tools: list_agents, create_agent, and message_agent as an @intx/agent tool bundle — lists and deploys specialist agents as workflow assets over stock Interchange routes, and mails a specialist directly. Creation is free; the reactor never parks it", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/agent-directory" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:", - "isomorphic-git": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - } -} diff --git a/tools/agent-directory/src/client.test.ts b/tools/agent-directory/src/client.test.ts deleted file mode 100644 index ffe0efa286..0000000000 --- a/tools/agent-directory/src/client.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - createAgentDefinition, - CreateAgentDefinitionError, - listAgentDefinitions, - messageAgent, - type AgentDirectoryToolClientConfig, -} from "./client"; - -const TENANT_BASE = "https://hub.example.com/api/tenants/ten_1"; - -function testConfig( - fetchImpl: typeof fetch, - overrides: Partial = {}, -): AgentDirectoryToolClientConfig { - return { - hubAgentDirectoryUrl: "https://hub.example.com", - tenantId: "ten_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - pushSourceTreeImpl: async () => "deadbeef", - ...overrides, - }; -} - -function jsonRoutes(routes: Record): typeof fetch { - return (async (url: string | URL, init?: RequestInit) => { - const raw = String(url); - for (const [suffix, body] of Object.entries(routes)) { - if (raw.endsWith(suffix)) { - return typeof body === "function" - ? (body as (init?: RequestInit) => Response)(init) - : Response.json(body); - } - } - throw new Error(`unexpected fetch: ${raw}`); - }) as unknown as typeof fetch; -} - -test("listAgentDefinitions reads workflow assets named agent--source and derives each address", async () => { - const fetchImpl = jsonRoutes({ - "/assets?kind=workflow&inherited=false": [ - { id: "asset_1", name: "agent-research-buddy-source", displayName: "Research Buddy" }, - { id: "asset_myra", name: "myra-source", displayName: "Myra" }, - ], - "/tenants/ten_1": { domain: "acme.example" }, - }); - - const definitions = await listAgentDefinitions(testConfig(fetchImpl)); - expect(definitions).toEqual([ - { id: "asset_1", name: "Research Buddy", address: "research-buddy@acme.example" }, - ]); -}); - -test("listAgentDefinitions reports honestly when the workbench has no agents", async () => { - const fetchImpl = jsonRoutes({ - "/assets?kind=workflow&inherited=false": [], - "/tenants/ten_1": { domain: "acme.example" }, - }); - expect(await listAgentDefinitions(testConfig(fetchImpl))).toEqual([]); -}); - -test("createAgentDefinition rejects an empty name without any network call", async () => { - const fetchImpl = (async () => { - throw new Error("should not be called"); - }) as unknown as typeof fetch; - await expect( - createAgentDefinition(testConfig(fetchImpl), { name: " ", systemPrompt: "x" }), - ).rejects.toBeInstanceOf(CreateAgentDefinitionError); -}); - -test("createAgentDefinition fails closed when no model provider is connected", async () => { - const fetchImpl = jsonRoutes({ - "/tenants/ten_1": { domain: "acme.example" }, - "/models": [], - }); - await expect( - createAgentDefinition(testConfig(fetchImpl), { - name: "Research Buddy", - systemPrompt: "You are a careful research assistant.", - }), - ).rejects.toThrow(/connect a model provider/i); -}); - -test("createAgentDefinition ensures the source asset, pushes it, and deploys through the stock route", async () => { - const seen: string[] = []; - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - const raw = String(url); - seen.push(`${init?.method ?? "GET"} ${raw}`); - if (raw.endsWith("/tenants/ten_1")) return Response.json({ domain: "acme.example" }); - if (raw.endsWith("/models")) { - return Response.json([ - { - canonicalName: "claude-sonnet-5", - offerings: [{ offeringId: "off_1", priority: 0, plugin: "anthropic" }], - }, - ]); - } - if (raw.endsWith("/assets") && init?.method === "POST") { - return new Response(JSON.stringify({ id: "asset_1" }), { status: 201 }); - } - if (raw.endsWith("/git-tokens") && init?.method === "POST") { - return Response.json({ id: "tok_1", secret: "sec_1" }); - } - if (raw.includes("/git-tokens/") && init?.method === "DELETE") { - return new Response(null, { status: 204 }); - } - if (raw.endsWith("/workflows/deployments") && init?.method === "POST") { - return Response.json({ id: "dep_1", definitionAssetId: "asset_1", status: "deploying" }); - } - throw new Error(`unexpected fetch: ${raw}`); - }) as unknown as typeof fetch; - - const result = await createAgentDefinition(testConfig(fetchImpl), { - name: "Research Buddy", - systemPrompt: "You are a careful research assistant.", - }); - - expect(result).toEqual({ - id: "asset_1", - name: "Research Buddy", - address: "research-buddy@acme.example", - deploymentId: "dep_1", - modelNote: null, - }); - expect(seen.some((s) => s === `POST ${TENANT_BASE}/workflows/deployments`)).toBe(true); -}); - -test("createAgentDefinition finds the existing asset on a 409 name conflict", async () => { - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - const raw = String(url); - if (raw.endsWith("/tenants/ten_1")) return Response.json({ domain: "acme.example" }); - if (raw.endsWith("/models")) { - return Response.json([ - { - canonicalName: "claude-sonnet-5", - offerings: [{ offeringId: "off_1", priority: 0, plugin: "anthropic" }], - }, - ]); - } - if (raw.endsWith("/assets") && init?.method === "POST") { - return new Response(JSON.stringify({ error: { code: "conflict", message: "exists" } }), { - status: 409, - }); - } - if (raw.endsWith("/assets?kind=workflow&inherited=false")) { - return Response.json([{ id: "asset_existing", name: "agent-research-buddy-source" }]); - } - if (raw.endsWith("/git-tokens") && init?.method === "POST") { - return Response.json({ id: "tok_1", secret: "sec_1" }); - } - if (raw.includes("/git-tokens/") && init?.method === "DELETE") { - return new Response(null, { status: 204 }); - } - if (raw.endsWith("/workflows/deployments") && init?.method === "POST") { - return Response.json({ - id: "dep_1", - definitionAssetId: "asset_existing", - status: "deploying", - }); - } - throw new Error(`unexpected fetch: ${raw}`); - }) as unknown as typeof fetch; - - const result = await createAgentDefinition(testConfig(fetchImpl), { - name: "Research Buddy", - systemPrompt: "You are a careful research assistant.", - }); - expect(result.id).toBe("asset_existing"); -}); - -test("createAgentDefinition notes a model substitution when the request names a model outside the catalog", async () => { - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - const raw = String(url); - if (raw.endsWith("/tenants/ten_1")) return Response.json({ domain: "acme.example" }); - if (raw.endsWith("/models")) { - return Response.json([ - { - canonicalName: "ollama/llama3", - offerings: [{ offeringId: "off_1", priority: 0, plugin: "ollama" }], - }, - ]); - } - if (raw.endsWith("/assets") && init?.method === "POST") { - return new Response(JSON.stringify({ id: "asset_1" }), { status: 201 }); - } - if (raw.endsWith("/git-tokens") && init?.method === "POST") { - return Response.json({ id: "tok_1", secret: "sec_1" }); - } - if (raw.includes("/git-tokens/") && init?.method === "DELETE") { - return new Response(null, { status: 204 }); - } - if (raw.endsWith("/workflows/deployments") && init?.method === "POST") { - return Response.json({ id: "dep_1", definitionAssetId: "asset_1", status: "deploying" }); - } - throw new Error(`unexpected fetch: ${raw}`); - }) as unknown as typeof fetch; - - const result = await createAgentDefinition(testConfig(fetchImpl), { - name: "Research Buddy", - systemPrompt: "You are a careful research assistant.", - model: "gpt-4o", - }); - expect(result.modelNote).toMatch(/gpt-4o/); - expect(result.modelNote).toMatch(/ollama\/llama3/); -}); - -test("messageAgent posts to the stock mailbox send route", async () => { - let seenUrl: string | undefined; - let seenBody: unknown; - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seenUrl = String(url); - seenBody = JSON.parse(String(init?.body)); - return Response.json({ messageId: "msg_1", uid: 1 }); - }) as unknown as typeof fetch; - - const result = await messageAgent(testConfig(fetchImpl), { - address: "research-buddy@acme.example", - message: "Please look into X.", - }); - expect(seenUrl).toBe(`${TENANT_BASE}/mailbox/me/inbox/send`); - expect(seenBody).toMatchObject({ - to: ["research-buddy@acme.example"], - body: "Please look into X.", - }); - expect(result.messageId).toBe("msg_1"); -}); diff --git a/tools/agent-directory/src/client.ts b/tools/agent-directory/src/client.ts deleted file mode 100644 index 8109cc5833..0000000000 --- a/tools/agent-directory/src/client.ts +++ /dev/null @@ -1,488 +0,0 @@ -// Every call here is a stock `@intx/hub-api` route reached with a -// workflow-run bearer, mirroring `tools/access`'s and -// `tools/workflow-authoring`'s clients: no workbench-specific mount. An -// agent is a `workflow`-kind asset named `agent--source` (the same -// convention `apps/web/src/agent-deploy.ts` uses for the hand-authored -// create-agent panel), pushed and deployed the same way that panel does, -// so a specialist Myra creates and one created from the web look -// identical to every other reader. -import { type } from "arktype"; - -import { pushSourceTree } from "./git-push"; -import { isValidSlug, slugify } from "./slug"; - -export interface AgentDirectoryToolClientConfig { - /** The hub's plain HTTP origin. */ - readonly hubAgentDirectoryUrl: string; - /** The run's own tenant — the `:tenantId` segment of every stock route. */ - readonly tenantId: string; - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; - /** Override for tests; defaults to the real git-over-HTTP push. Real - * git plumbing has no meaningful unit-testable surface of its own - * (see `./git-push.ts`), so tests substitute a stub here instead of - * standing up a git-receive-pack server. */ - readonly pushSourceTreeImpl?: typeof pushSourceTree; -} - -export interface ListedAgentDefinition { - readonly id: string; - readonly name: string; - /** The agent's live mail address — pass this to {@link messageAgent}. */ - readonly address: string; -} - -export interface CreateAgentDefinitionRequest { - readonly name: string; - readonly systemPrompt: string; - /** A canonical model name from the tenant's own catalog. Falls back to - * the catalog's default (with {@link CreatedAgentDefinition.modelNote} - * set) when it names nothing in the catalog. */ - readonly model?: string; -} - -export interface CreatedAgentDefinition { - readonly id: string; - readonly name: string; - readonly address: string; - readonly deploymentId: string; - /** Set when `model` was requested but the tenant's catalog didn't offer - * it, so the default was used instead of baking in a name that can - * never resolve. `null` when no substitution was needed. */ - readonly modelNote: string | null; -} - -export interface MessageAgentRequest { - readonly address: string; - readonly message: string; -} - -export interface MessageAgentResult { - readonly messageId: string; -} - -/** Thrown when creating or deploying the agent's source is refused for a - * reason the caller should relay honestly (a bad name, no connected model - * provider) rather than a bare transport/HTTP failure. */ -export class CreateAgentDefinitionError extends Error {} - -function authHeaders(config: AgentDirectoryToolClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -function tenantBase(config: AgentDirectoryToolClientConfig): string { - return `${config.hubAgentDirectoryUrl}/api/tenants/${encodeURIComponent(config.tenantId)}`; -} - -/** Pulls `error.userMessage` out of the canonical hub envelope - * (`{error: {code, userMessage, refId}}`), if `body` matches that shape. */ -function errorMessageFrom(body: unknown): string | undefined { - if (body === null || typeof body !== "object" || !("error" in body)) { - return undefined; - } - const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("userMessage" in error)) { - return undefined; - } - const userMessage = (error as { userMessage: unknown }).userMessage; - return typeof userMessage === "string" ? userMessage : undefined; -} - -async function readErrorMessage(response: Response, fallback: string): Promise { - const body: unknown = await response.json().catch(() => undefined); - return errorMessageFrom(body) ?? fallback; -} - -function parseOrThrow( - schema: (value: unknown) => T | type.errors, - body: unknown, - operation: string, -): T { - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new Error(`${operation} response did not match the expected shape: ${parsed.summary}`); - } - return parsed; -} - -const AGENT_SOURCE_ASSET_NAME = /^agent-(.+)-source$/; - -/** Every created agent's source asset name — the one naming convention - * this pipeline owns, mirroring `apps/web/src/agent-deploy.ts`'s - * `agentDeploySourceAssetName`. */ -function agentDeploySourceAssetName(slug: string): string { - return `agent-${slug}-source`; -} - -const WORKFLOW_SOURCE_ENTRY = "./workflow.js"; - -/** The source tree a serialized, function-free definition renders into — - * mirrors `@corbits/workflows`' `renderWorkflowSourceTree`, duplicated - * here rather than pulling that package's server-only dependency graph - * (`@intx/hub-api`, `drizzle-orm`, `hono`) into this trim tool bundle. */ -function renderWorkflowSourceTree(args: { - readonly packageName: string; - readonly workflowJson: string; -}): Readonly> { - return { - "package.json": `${JSON.stringify( - { - name: args.packageName, - version: "0.0.0", - private: true, - type: "module", - interchange: { workflow: WORKFLOW_SOURCE_ENTRY }, - }, - null, - 2, - )}\n`, - "workflow.js": `export default ${args.workflowJson};\n`, - "definition.json": `${args.workflowJson}\n`, - }; -} - -const AssetListEntry = type({ - id: "string", - name: "string", - "displayName?": "string | null", -}); - -const TenantDomainResponse = type({ domain: "string" }); -const AssetCreatedResponse = type({ id: "string" }); -const GitTokenMintResponse = type({ id: "string", secret: "string" }); -const DeployResponse = type({ id: "string", definitionAssetId: "string", status: "string" }); -const SendAcceptedResponse = type({ messageId: "string" }); - -const DiscoveredModels = type({ - canonicalName: "string", - offerings: type({ offeringId: "string", priority: "number", plugin: "string" }).array(), -}).array(); - -const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000; -const AGENT_TURN_TIMEOUT_MS = 2 * 60 * 1000; - -/** Every taskable agent already deployed in this tenant, resolved to its - * source asset id and live mail address (`@`). */ -export async function listAgentDefinitions( - config: AgentDirectoryToolClientConfig, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const [assetsResponse, tenantResponse] = await Promise.all([ - doFetch(`${tenantBase(config)}/assets?kind=workflow&inherited=false`, { - headers: authHeaders(config), - }), - doFetch(tenantBase(config), { headers: authHeaders(config) }), - ]); - if (!assetsResponse.ok) { - throw new Error( - `Listing agents failed: ${await readErrorMessage(assetsResponse, `${assetsResponse.status} ${assetsResponse.statusText}`)}`, - ); - } - if (!tenantResponse.ok) { - throw new Error( - `Resolving this workbench's domain failed: ${await readErrorMessage(tenantResponse, `${tenantResponse.status} ${tenantResponse.statusText}`)}`, - ); - } - const assets = parseOrThrow( - AssetListEntry.array(), - await assetsResponse.json(), - "Listing agents", - ); - const tenant = parseOrThrow( - TenantDomainResponse, - await tenantResponse.json(), - "Resolving this workbench's domain", - ); - const definitions: ListedAgentDefinition[] = []; - for (const asset of assets) { - const match = AGENT_SOURCE_ASSET_NAME.exec(asset.name); - if (match === null) continue; - const slug = match[1]; - definitions.push({ - id: asset.id, - name: asset.displayName ?? asset.name, - address: `${slug}@${tenant.domain}`, - }); - } - return definitions; -} - -/** Create-or-find this agent's source asset by name, mirroring - * `apps/web/src/agent-deploy.ts`'s `ensureAgentSourceAsset`. */ -async function ensureAgentSourceAsset( - config: AgentDirectoryToolClientConfig, - assetName: string, - displayName: string, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const created = await doFetch(`${tenantBase(config)}/assets`, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify({ kind: "workflow", name: assetName, displayName }), - }); - if (created.status === 201) { - return parseOrThrow(AssetCreatedResponse, await created.json(), "Preparing this agent's source") - .id; - } - if (created.status !== 409) { - throw new CreateAgentDefinitionError( - `preparing this agent's source failed: ${await readErrorMessage(created, `${created.status} ${created.statusText}`)}`, - ); - } - const listed = await doFetch(`${tenantBase(config)}/assets?kind=workflow&inherited=false`, { - headers: authHeaders(config), - }); - if (!listed.ok) { - throw new CreateAgentDefinitionError( - `checking this workbench's agents failed: ${await readErrorMessage(listed, `${listed.status} ${listed.statusText}`)}`, - ); - } - const assets = parseOrThrow( - AssetListEntry.array(), - await listed.json(), - "Checking this workbench's agents", - ); - const existing = assets.find((asset) => asset.name === assetName); - if (existing === undefined) { - throw new CreateAgentDefinitionError( - "this agent's source reported a name conflict but is not listed on this workbench", - ); - } - return existing.id; -} - -/** Mints a short-lived push token and pushes `tree` to the asset's - * `main`, always revoking the token afterward. */ -async function withPushToken( - config: AgentDirectoryToolClientConfig, - assetId: string, - push: (token: string) => Promise, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const tokensPath = `${tenantBase(config)}/git-tokens`; - const minted = await doFetch(tokensPath, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify({ - name: `agent-deploy-${crypto.randomUUID()}`, - resource: `asset:${assetId}`, - refPattern: "refs/heads/main", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + PUSH_TOKEN_LIFETIME_MS).toISOString(), - }), - }); - if (!minted.ok) { - throw new CreateAgentDefinitionError( - `minting a push token failed: ${await readErrorMessage(minted, `${minted.status} ${minted.statusText}`)}`, - ); - } - const token = parseOrThrow(GitTokenMintResponse, await minted.json(), "Minting a push token"); - try { - return await push(token.secret); - } finally { - await doFetch(`${tokensPath}/${encodeURIComponent(token.id)}`, { - method: "DELETE", - headers: authHeaders(config), - }); - } -} - -/** The exact single-step, mail-triggered, unbounded-turn agent shape - * `apps/web/src/agent-deploy.ts`'s `buildAgentDefinitionJson` produces. */ -function buildAgentDefinitionJson(args: { - readonly slug: string; - readonly systemPrompt: string; - readonly triggerAddress: string; - readonly declaredSources: readonly { readonly provider: string; readonly model: string }[]; -}): unknown { - const stepId = "run"; - return { - id: args.slug, - triggers: [{ type: "mail", to: args.triggerAddress }], - steps: { - [stepId]: { - kind: "step", - id: stepId, - agent: { - id: stepId, - description: `The "${args.slug}" agent`, - systemPrompt: args.systemPrompt, - toolFactories: [], - capabilities: [], - inference: { sources: args.declaredSources.map((source) => ({ ...source })) }, - toolPackagePins: [], - }, - drainBehavior: "wait", - timeout: AGENT_TURN_TIMEOUT_MS, - triggers: "unbounded", - input: { from: "trigger.payload" }, - }, - }, - stepOrder: [stepId], - }; -} - -/** - * Creates a brand-new specialist agent: ensures its source asset, renders - * and pushes its definition, resolves the tenant's own catalog offerings, - * and deploys through the stock `POST /workflows/deployments` — the same - * pipeline `apps/web/src/agent-deploy.ts` runs for the hand-authored - * create-agent panel, reached here with a workflow-run bearer instead of - * a browser session. Fails closed when no provider is connected: there is - * no offering to deploy against. - */ -export async function createAgentDefinition( - config: AgentDirectoryToolClientConfig, - input: CreateAgentDefinitionRequest, -): Promise { - const name = input.name.trim(); - if (name === "") throw new CreateAgentDefinitionError("an agent needs a name"); - const systemPrompt = input.systemPrompt.trim(); - if (systemPrompt === "") throw new CreateAgentDefinitionError("an agent needs a system prompt"); - const slug = slugify(name); - if (!isValidSlug(slug)) { - throw new CreateAgentDefinitionError("this name doesn't produce a usable agent address"); - } - - const doFetch = config.fetchImpl ?? fetch; - const assetName = agentDeploySourceAssetName(slug); - const packageName = `@workbench-agent/${slug}`; - - const tenantResponse = await doFetch(tenantBase(config), { headers: authHeaders(config) }); - if (!tenantResponse.ok) { - throw new CreateAgentDefinitionError( - `resolving this workbench's domain failed: ${await readErrorMessage(tenantResponse, `${tenantResponse.status} ${tenantResponse.statusText}`)}`, - ); - } - const tenant = parseOrThrow( - TenantDomainResponse, - await tenantResponse.json(), - "Resolving this workbench's domain", - ); - - const modelsResponse = await doFetch(`${tenantBase(config)}/models`, { - headers: authHeaders(config), - }); - if (!modelsResponse.ok) { - throw new CreateAgentDefinitionError( - `reading this workbench's inference catalog failed: ${await readErrorMessage(modelsResponse, `${modelsResponse.status} ${modelsResponse.statusText}`)}`, - ); - } - const models = parseOrThrow( - DiscoveredModels, - await modelsResponse.json(), - "Reading this workbench's inference catalog", - ); - const flattened = models - .flatMap((model) => - model.offerings.map((offering) => ({ ...offering, model: model.canonicalName })), - ) - .sort((a, b) => a.priority - b.priority || a.offeringId.localeCompare(b.offeringId)); - const seen = new Set(); - const declaredSources = flattened - .filter((offering) => { - if (seen.has(offering.offeringId)) return false; - seen.add(offering.offeringId); - return true; - }) - .map((offering) => ({ provider: offering.plugin, model: offering.model })); - const sourceOfferingIds = declaredSources.length > 0 ? [...seen] : []; - const defaultSourceOfferingId = sourceOfferingIds[0]; - if (defaultSourceOfferingId === undefined) { - throw new CreateAgentDefinitionError( - "connect a model provider in Settings before creating an agent", - ); - } - - let modelNote: string | null = null; - let orderedDeclaredSources = declaredSources; - if (input.model !== undefined) { - const requestedIndex = orderedDeclaredSources.findIndex( - (source) => source.model === input.model, - ); - if (requestedIndex === -1) { - modelNote = `Requested model "${input.model}" is not in this workbench's catalog; used the workspace default "${orderedDeclaredSources[0]?.model ?? "none"}" instead.`; - } else if (requestedIndex > 0) { - const [requested] = orderedDeclaredSources.splice(requestedIndex, 1); - if (requested !== undefined) orderedDeclaredSources = [requested, ...orderedDeclaredSources]; - } - } - - const assetId = await ensureAgentSourceAsset(config, assetName, name); - const triggerAddress = `${slug}@${tenant.domain}`; - const workflowJson = buildAgentDefinitionJson({ - slug, - systemPrompt, - triggerAddress, - declaredSources: orderedDeclaredSources, - }); - const tree = renderWorkflowSourceTree({ - packageName, - workflowJson: JSON.stringify(workflowJson), - }); - const doPush = config.pushSourceTreeImpl ?? pushSourceTree; - const commitSha = await withPushToken(config, assetId, (token) => - doPush({ - url: `${tenantBase(config)}/assets/workflow/${assetName}.git`, - token, - tree, - message: `Publish ${assetName}'s definition`, - ...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}), - }), - ); - - const deployed = await doFetch(`${tenantBase(config)}/workflows/deployments`, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify({ - source: { kind: "asset", assetId, package: { format: "source", commitSha } }, - entry: "./workflow.js", - sourceOfferingIds, - defaultSourceOfferingId, - }), - }); - if (!deployed.ok) { - throw new CreateAgentDefinitionError( - `deploying this agent failed: ${await readErrorMessage(deployed, `${deployed.status} ${deployed.statusText}`)}`, - ); - } - const deployment = parseOrThrow(DeployResponse, await deployed.json(), "Deploying this agent"); - - return { - id: assetId, - name, - address: triggerAddress, - deploymentId: deployment.id, - modelNote, - }; -} - -/** Sends a mail-shaped message to an agent's live address through the - * stock mailbox, the same send this run's own mailbox uses for a reply. */ -export async function messageAgent( - config: AgentDirectoryToolClientConfig, - input: MessageAgentRequest, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch(`${tenantBase(config)}/mailbox/me/inbox/send`, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify({ - to: [input.address], - subject: input.message.slice(0, 60), - body: input.message, - }), - }); - if (!response.ok) { - throw new Error( - `Messaging the agent failed: ${await readErrorMessage(response, `${response.status} ${response.statusText}`)}`, - ); - } - const parsed = parseOrThrow(SendAcceptedResponse, await response.json(), "Messaging the agent"); - return { messageId: parsed.messageId }; -} diff --git a/tools/agent-directory/src/git-push.ts b/tools/agent-directory/src/git-push.ts deleted file mode 100644 index 87fbeb068d..0000000000 --- a/tools/agent-directory/src/git-push.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Pushes a small flat file tree into a hub asset repo from a workflow run. -// isomorphic-git builds the objects and the pack; the receive-pack wire -// exchange is spoken directly because the hub answers `report-status` as -// raw pkt-lines, which isomorphic-git's own push cannot read. Mirrors -// `apps/web/src/git-push.ts`'s browser version, swapped onto real node `fs` -// and a temp directory since this runs server-side in the sidecar. -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import fs from "node:fs"; -import git from "isomorphic-git"; - -export class GitPushError extends Error {} - -const COMMIT_AUTHOR = { name: "Workbench", email: "workbench@corbits.dev" }; -const MAIN_REF = "refs/heads/main"; -const ZERO_OID = "0".repeat(40); - -function pktLine(text: string): Uint8Array { - const payload = new TextEncoder().encode(text); - const header = (payload.length + 4).toString(16).padStart(4, "0"); - return new Uint8Array([...new TextEncoder().encode(header), ...payload]); -} - -function readPktLines(body: Uint8Array): string[] { - const decoder = new TextDecoder(); - const lines: string[] = []; - let offset = 0; - while (offset + 4 <= body.length) { - const length = parseInt(decoder.decode(body.subarray(offset, offset + 4)), 16); - offset += 4; - if (Number.isNaN(length)) throw new GitPushError("malformed pkt-line header from the hub"); - if (length === 0) continue; - lines.push(decoder.decode(body.subarray(offset, offset + length - 4))); - offset += length - 4; - } - return lines; -} - -async function advertisedMainSha( - url: string, - token: string, - fetchImpl: typeof fetch, -): Promise { - const response = await fetchImpl(`${url}/info/refs?service=git-receive-pack`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - throw new GitPushError(`ref advertisement failed: HTTP ${response.status}`); - } - const lines = readPktLines(new Uint8Array(await response.arrayBuffer())); - for (const line of lines) { - const [sha, rest] = line.split(" ", 2); - const ref = rest?.split("\0")[0]?.trim(); - if (sha !== undefined && ref === MAIN_REF) return sha; - } - return ZERO_OID; -} - -/** Commits `tree` on top of the asset's current `main` and pushes it. - * Returns the new commit sha. */ -export async function pushSourceTree(args: { - readonly url: string; - readonly token: string; - readonly tree: Readonly>; - readonly message: string; - readonly fetchImpl?: typeof fetch; -}): Promise { - const doFetch = args.fetchImpl ?? fetch; - const dir = await mkdtemp(join(tmpdir(), "agent-directory-push-")); - try { - await git.init({ fs, dir, defaultBranch: "main" }); - for (const [filepath, contents] of Object.entries(args.tree)) { - if (filepath.includes("/")) { - throw new GitPushError( - `pushSourceTree writes a flat tree; got ${JSON.stringify(filepath)}`, - ); - } - await fs.promises.writeFile(join(dir, filepath), contents, "utf8"); - await git.add({ fs, dir, filepath }); - } - const oldSha = await advertisedMainSha(args.url, args.token, doFetch); - const sha = await git.commit({ - fs, - dir, - message: args.message, - author: COMMIT_AUTHOR, - parent: oldSha === ZERO_OID ? [] : [oldSha], - }); - const { commit } = await git.readCommit({ fs, dir, oid: sha }); - const { tree } = await git.readTree({ fs, dir, oid: commit.tree }); - const { packfile } = await git.packObjects({ - fs, - dir, - oids: [sha, commit.tree, ...tree.map((entry) => entry.oid)], - }); - if (packfile === undefined) throw new GitPushError("packObjects returned no packfile"); - - const command = pktLine(`${oldSha} ${sha} ${MAIN_REF}\0report-status\n`); - const body = new Uint8Array(command.length + 4 + packfile.length); - body.set(command, 0); - body.set(new TextEncoder().encode("0000"), command.length); - body.set(packfile, command.length + 4); - const response = await doFetch(`${args.url}/git-receive-pack`, { - method: "POST", - headers: { - Authorization: `Bearer ${args.token}`, - "content-type": "application/x-git-receive-pack-request", - }, - body, - }); - if (!response.ok) { - throw new GitPushError(`git push failed: HTTP ${response.status}`); - } - const report = readPktLines(new Uint8Array(await response.arrayBuffer())); - const refusal = report.find( - (line) => - line.startsWith("ng ") || (line.startsWith("unpack ") && line.trim() !== "unpack ok"), - ); - if (refusal !== undefined) { - throw new GitPushError(`git push was refused: ${refusal.trim()}`); - } - if (!report.some((line) => line.trim() === `ok ${MAIN_REF}`)) { - throw new GitPushError(`git push reported no result for ${MAIN_REF}`); - } - return sha; - } finally { - await rm(dir, { recursive: true, force: true }); - } -} diff --git a/tools/agent-directory/src/index.ts b/tools/agent-directory/src/index.ts deleted file mode 100644 index 4a2c2d52c7..0000000000 --- a/tools/agent-directory/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - createAgentDefinition, - CreateAgentDefinitionError, - listAgentDefinitions, - messageAgent, - type AgentDirectoryToolClientConfig, - type CreateAgentDefinitionRequest, - type CreatedAgentDefinition, - type ListedAgentDefinition, - type MessageAgentRequest, - type MessageAgentResult, -} from "./client"; -export { - agentDirectoryTools, - CREATE_AGENT_TOOL, - LIST_AGENTS_TOOL, - MESSAGE_AGENT_TOOL, - type WorkflowAgentDirectoryEnv, -} from "./tool"; diff --git a/tools/agent-directory/src/slug.ts b/tools/agent-directory/src/slug.ts deleted file mode 100644 index f0649fe76b..0000000000 --- a/tools/agent-directory/src/slug.ts +++ /dev/null @@ -1,19 +0,0 @@ -// A trigger address needs a URL/mail-safe slug. Mirrors -// `apps/web/src/lib/slug/slug.ts` closely enough to derive the same -// address a hand-authored agent gets from the web create-agent panel, -// without this bundle depending on `apps/web`. -const SLUG_MAX_LENGTH = 64; -const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -export function slugify(name: string): string { - const ascii = name - .normalize("NFKD") - .replace(/[̀-ͯ]/g, "") - .toLowerCase(); - const hyphenated = ascii.replace(/[^a-z0-9]+/g, "-"); - return hyphenated.slice(0, SLUG_MAX_LENGTH).replace(/^-+|-+$/g, ""); -} - -export function isValidSlug(s: string): boolean { - return s.length > 0 && s.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(s); -} diff --git a/tools/agent-directory/src/tool.test.ts b/tools/agent-directory/src/tool.test.ts deleted file mode 100644 index a3f5a6f0f8..0000000000 --- a/tools/agent-directory/src/tool.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { - agentDirectoryTools, - CREATE_AGENT_TOOL, - LIST_AGENTS_TOOL, - MESSAGE_AGENT_TOOL, - type WorkflowAgentDirectoryEnv, -} from "./tool"; - -function testEnv(): WorkflowAgentDirectoryEnv { - return { - hubAgentDirectoryUrl: "https://hub.example.com", - tenantId: "ten_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - } as unknown as WorkflowAgentDirectoryEnv; -} - -function callFor(name: string, args: Record): ToolCall { - return { id: "call_1", name, arguments: args }; -} - -test("declares list_agents, create_agent, and message_agent", () => { - const bundle = agentDirectoryTools(testEnv()); - expect(bundle.definitions.map((d) => d.name)).toEqual([ - LIST_AGENTS_TOOL, - CREATE_AGENT_TOOL, - MESSAGE_AGENT_TOOL, - ]); -}); - -test("requires the sanctioned env keys", () => { - expect(agentDirectoryTools.requires).toEqual([ - "hubAgentDirectoryUrl", - "tenantId", - "sidecarToken", - "address", - ]); -}); - -test("none of the tools declare an approval key", () => { - expect(agentDirectoryTools.definitions).toEqual([ - { name: LIST_AGENTS_TOOL }, - { name: CREATE_AGENT_TOOL }, - { name: MESSAGE_AGENT_TOOL }, - ]); -}); - -test("create_agent's modelPreference field tells the model to omit it rather than guess a name", () => { - const bundle = agentDirectoryTools(testEnv()); - const definition = bundle.definitions[1] as unknown as { - inputSchema: { properties: { modelPreference: { description: string } } }; - }; - const description = definition.inputSchema.properties.modelPreference.description; - expect(description).toMatch(/omit/i); - expect(description).toMatch(/do not (guess|invent)/i); -}); - -test("create_agent's input schema requires name and systemPrompt only", () => { - const bundle = agentDirectoryTools(testEnv()); - const definition = bundle.definitions[1] as unknown as { inputSchema: { required: string[] } }; - expect(definition.inputSchema.required).toEqual(["name", "systemPrompt"]); -}); - -test("create_agent rejects a call missing a required field", async () => { - const bundle = agentDirectoryTools(testEnv()); - const result = await bundle.run( - callFor(CREATE_AGENT_TOOL, { name: "Research Buddy" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/invalid input/); -}); - -test("message_agent rejects a call missing a required field", async () => { - const bundle = agentDirectoryTools(testEnv()); - const result = await bundle.run( - callFor(MESSAGE_AGENT_TOOL, { address: "a@b.example" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/invalid input/); -}); - -test("an unknown tool name returns an honest error", async () => { - const bundle = agentDirectoryTools(testEnv()); - const result = await bundle.run( - { id: "call_1", name: "delete_everything", arguments: {} }, - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/unknown tool/); -}); - -test("list_agents reports the tenant's taskable agents by address", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url: string | URL) => { - const raw = String(url); - if (raw.endsWith("/assets?kind=workflow&inherited=false")) { - return Response.json([ - { id: "asset_1", name: "agent-research-buddy-source", displayName: "Research Buddy" }, - ]); - } - return Response.json({ domain: "acme.example" }); - }) as unknown as typeof fetch; - try { - const bundle = agentDirectoryTools(testEnv()); - const result = await bundle.run(callFor(LIST_AGENTS_TOOL, {}), new AbortController().signal); - expect(result.isError).toBeFalsy(); - expect(result.content).toContain("Research Buddy"); - expect(result.content).toContain("research-buddy@acme.example"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("list_agents reports honestly when the workbench has no other agents", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url: string | URL) => { - if (String(url).endsWith("/assets?kind=workflow&inherited=false")) return Response.json([]); - return Response.json({ domain: "acme.example" }); - }) as unknown as typeof fetch; - try { - const bundle = agentDirectoryTools(testEnv()); - const result = await bundle.run(callFor(LIST_AGENTS_TOOL, {}), new AbortController().signal); - expect(result.isError).toBeFalsy(); - expect(result.content).toMatch(/No other agents/); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("message_agent sends through the stock mailbox route", async () => { - const originalFetch = globalThis.fetch; - let seenUrl: string | undefined; - globalThis.fetch = (async (url: string | URL) => { - seenUrl = String(url); - return Response.json({ messageId: "msg_1", uid: 1 }); - }) as unknown as typeof fetch; - try { - const bundle = agentDirectoryTools(testEnv()); - const result = await bundle.run( - callFor(MESSAGE_AGENT_TOOL, { address: "research-buddy@acme.example", message: "hi" }), - new AbortController().signal, - ); - expect(result.isError).toBeFalsy(); - expect(result.content).toMatch(/Message sent/); - expect(seenUrl).toContain("/mailbox/me/inbox/send"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("message_agent surfaces a send failure honestly", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => - new Response(JSON.stringify({ error: { code: "not_found", userMessage: "no such agent" } }), { - status: 404, - })) as unknown as typeof fetch; - try { - const bundle = agentDirectoryTools(testEnv()); - const result = await bundle.run( - callFor(MESSAGE_AGENT_TOOL, { address: "ghost@acme.example", message: "hi" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/no such agent/); - } finally { - globalThis.fetch = originalFetch; - } -}); diff --git a/tools/agent-directory/src/tool.ts b/tools/agent-directory/src/tool.ts deleted file mode 100644 index 5c7ec03516..0000000000 --- a/tools/agent-directory/src/tool.ts +++ /dev/null @@ -1,222 +0,0 @@ -// Three tools, no approval key: listing is read-only, creation is -// tenant-internal and free (a new, deployed source asset), and messaging -// an already-deployed specialist is ordinary mail. See ./client.ts for -// the stock, workflow-run-authenticated routes each one calls. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { type } from "arktype"; - -import { - createAgentDefinition, - CreateAgentDefinitionError, - listAgentDefinitions, - messageAgent, - type AgentDirectoryToolClientConfig, -} from "./client"; - -export const LIST_AGENTS_TOOL = "list_agents"; -export const CREATE_AGENT_TOOL = "create_agent"; -export const MESSAGE_AGENT_TOOL = "message_agent"; - -export interface WorkflowAgentDirectoryEnv extends BaseEnv { - readonly hubAgentDirectoryUrl: string; - readonly tenantId: string; - readonly sidecarToken: string; - readonly address: string; -} - -const CreateAgentInput = type({ - name: "string > 0", - systemPrompt: "string > 0", - "modelPreference?": "string > 0", -}); -type CreateAgentInput = typeof CreateAgentInput.infer; - -const MessageAgentInput = type({ - address: "string > 0", - message: "string > 0", -}); -type MessageAgentInput = typeof MessageAgentInput.infer; - -function errorResult(callId: string, err: unknown): ToolResult { - return { - callId, - isError: true, - content: err instanceof Error ? err.message : String(err), - }; -} - -function clientConfig(env: WorkflowAgentDirectoryEnv): AgentDirectoryToolClientConfig { - return { - hubAgentDirectoryUrl: env.hubAgentDirectoryUrl, - tenantId: env.tenantId, - sidecarToken: env.sidecarToken, - address: env.address, - }; -} - -async function runListAgents(env: WorkflowAgentDirectoryEnv, call: ToolCall): Promise { - try { - const definitions = await listAgentDefinitions(clientConfig(env)); - const content = - definitions.length === 0 - ? "No other agents exist in this workbench yet." - : definitions - .map((definition) => `${definition.name} (message this address: ${definition.address})`) - .join("\n"); - return { callId: call.id, isError: false, content }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runCreateAgent(env: WorkflowAgentDirectoryEnv, call: ToolCall): Promise { - const parsed = CreateAgentInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`create_agent received invalid input: ${parsed.summary}`), - ); - } - - try { - const created = await createAgentDefinition(clientConfig(env), { - name: parsed.name, - systemPrompt: parsed.systemPrompt, - ...(parsed.modelPreference !== undefined ? { model: parsed.modelPreference } : {}), - }); - const modelSuffix = created.modelNote !== null ? ` ${created.modelNote}` : ""; - return { - callId: call.id, - isError: false, - content: `Created "${created.name}" and deployed it (message it at ${created.address}).${modelSuffix}`, - }; - } catch (err) { - if (err instanceof CreateAgentDefinitionError) { - return errorResult(call.id, err); - } - return errorResult(call.id, err); - } -} - -async function runMessageAgent( - env: WorkflowAgentDirectoryEnv, - call: ToolCall, -): Promise { - const parsed = MessageAgentInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`message_agent received invalid input: ${parsed.summary}`), - ); - } - try { - await messageAgent(clientConfig(env), parsed); - return { - callId: call.id, - isError: false, - content: `Message sent to ${parsed.address}.`, - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -/** - * The `@corbits/agent-directory-tools` bundle factory: `list_agents` - * (read, no approval), `create_agent` (creation is free; the reactor - * never parks this call), and `message_agent` — Myra's self-service - * specialist-creation and dispatch path. - */ -export const agentDirectoryTools = defineTool({ - id: "@corbits/agent-directory-tools/ad", - requires: ["hubAgentDirectoryUrl", "tenantId", "sidecarToken", "address"], - definitions: [ - { name: LIST_AGENTS_TOOL }, - { name: CREATE_AGENT_TOOL }, - { name: MESSAGE_AGENT_TOOL }, - ], - factory: (env) => ({ - definitions: [ - { - name: LIST_AGENTS_TOOL, - description: - "List the other taskable agents already in this workbench — " + - "use this before creating a new one, so you never create a " + - "duplicate of an agent that already exists, and to find an " + - "existing agent's address before messaging it.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: CREATE_AGENT_TOOL, - description: - "Create and deploy a brand-new specialist agent in this " + - "workbench, with its own name, system prompt, and mail " + - "address. Use this only for a genuine, specific need; never " + - "speculatively.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: 'The new agent\'s display name, e.g. "Release Notes Writer".', - }, - systemPrompt: { - type: "string", - description: "The new agent's full system prompt.", - }, - modelPreference: { - type: "string", - description: - "A canonical model name from this workspace's own " + - "connected catalog — check list_agents or a models " + - "listing tool for real names first. Do not guess or " + - "invent a name (e.g. a well-known provider model like " + - '"gpt-4o") on the assumption it is available: a name ' + - "outside this workspace's catalog is never used and " + - "falls back to the workspace default instead. Omit " + - "this field entirely to use that default.", - }, - }, - required: ["name", "systemPrompt"], - }, - }, - { - name: MESSAGE_AGENT_TOOL, - description: "Send a mail message to another agent in this workbench by its address.", - inputSchema: { - type: "object", - properties: { - address: { - type: "string", - description: "The agent's mail address, from list_agents or create_agent.", - }, - message: { - type: "string", - description: "The message body to send.", - }, - }, - required: ["address", "message"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - switch (call.name) { - case LIST_AGENTS_TOOL: - return runListAgents(env, call); - case CREATE_AGENT_TOOL: - return runCreateAgent(env, call); - case MESSAGE_AGENT_TOOL: - return runMessageAgent(env, call); - default: - return Promise.resolve( - errorResult( - call.id, - new Error(`@corbits/agent-directory-tools: unknown tool "${call.name}"`), - ), - ); - } - }, - }), -}); diff --git a/tools/agent-directory/tsconfig.json b/tools/agent-directory/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/agent-directory/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/agent-directory/tsconfig.src.json b/tools/agent-directory/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/agent-directory/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/capability/LICENSE b/tools/capability/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/capability/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/capability/README.md b/tools/capability/README.md deleted file mode 100644 index 7456900d12..0000000000 --- a/tools/capability/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# @corbits/capability-tools - -The `request_capability` `@intx/agent` tool bundle: an agent's -in-chat way to ask for a tool package, skill, or model it doesn't have -yet. Declared `approval: "ask"` — Interchange's native per-invocation -gate suspends the call as a pending approval and renders it in chat -before this bundle's own code ever runs; a human has to allow it before -the addition happens. - -## The tool - -- `request_capability({ kind, name, why, title? })` — `kind` is - `"tool-package" | "skill" | "model"`, `name` is the capability's exact - name as offered by the workspace's live inventory (never invented), - `why` is a one-sentence reason shown on the approval card, and - `title` is an optional short human label (e.g. `"GitHub tools"`) that - makes the approval card read naturally. - -On approval, the execution calls the workflow-run capabilities surface -for the calling agent's own definition. Success comes back as a plain -result message ("Added @corbits/example-tools — I can use it from my -next reply."); an out-of-inventory request comes back naming what's -actually available, never a fabricated success. Any other transport or -HTTP failure comes back as an honest `isError: true` result. - -## Activated end-to-end - -Both gaps this package was originally written against are now closed: - -1. **Definition-id context.** `env.definitionId` is threaded from - `apps/sidecar/src/workflow-substrate-factory/step-env.ts`, following - exactly the binding pattern `@corbits/memory-tools`' `hubMemoryUrl`/ - `sidecarToken`/`address` use — the run's own definition id, resolved - at the substrate factory from `WORKFLOW_DEFINITION_REPO_ID`. -2. **Workflow-run-authenticated capabilities route.** - `@corbits/agent-directory`'s `createWorkflowCapabilityRoutes` - (`packages/agent-directory/src/workflow-capability-routes.ts`) mounts - at `/api/workflow-capabilities` in `apps/hub`, beside - `/api/workflow-skills`. It authenticates via the same - `createWorkflowRunAuthenticator` (sidecar bearer token + run - address), and constrains a call to the caller's OWN definitionId - (403 on any other target). - - That route deliberately does NOT gate on a grant-store check for the - own-definition case — Interchange's grant materialization still - never seeds a `kind: "workflow"` run's principal an `update` grant on - its own definition. Instead the route relies on the approval that - already happened: this - bundle's `request_capability` tool is declared `approval: "ask"`, so - the reactor suspends every call as a pending approval and renders it - in-chat BEFORE this bundle's `run` — and therefore before the route - is ever called — executes. The human who approved the card is the - authorizer; the route's own unconditional checks (own-definition-only, - fail-closed inventory) are what it enforces beyond that. See the - route's own file-level comment for the full reasoning. - -`@corbits/capability-tools` is pinned into every drafted agent's default -tool-package set whenever the tenant's inventory offers it -(`packages/agent-directory/src/agent-definition-drafting.ts`), and listed -in `apps/hub`'s `listMyraUsableToolPackages`. - -## Usage - -```ts -import { capabilityTools } from "@corbits/capability-tools"; - -const agent = defineAgent({ - // ... - tools: [capabilityTools], -}); -``` - -## Running tests - -```sh -cd tools/capability && bun test -``` - -Tests run against a mocked fetch/`WorkflowCapabilityEnv`; no `DATABASE_URL` -or live hub is required. diff --git a/tools/capability/package.json b/tools/capability/package.json deleted file mode 100644 index df5f652f24..0000000000 --- a/tools/capability/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@corbits/capability-tools", - "version": "0.0.7", - "description": "The request_capability tool as an @intx/agent tool bundle: an agent asks in-chat for a tool package, skill, or model it doesn't have, gated behind Interchange's native per-invocation approval, and — once approved — calls the sanctioned workflow-run capabilities surface with the run's own bearer token, never a model-supplied identity", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/capability" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - } -} diff --git a/tools/capability/src/client.test.ts b/tools/capability/src/client.test.ts deleted file mode 100644 index 3819c8e122..0000000000 --- a/tools/capability/src/client.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - addCapability, - CapabilityOutOfInventoryError, - fetchCapabilityInventory, - type CapabilityToolClientConfig, -} from "./client"; - -function testConfig(fetchImpl: typeof fetch): CapabilityToolClientConfig { - return { - hubCapabilitiesUrl: "https://hub.example.com", - sidecarToken: "sc-token", - address: "run_1@workflow", - definitionId: "def_1", - fetchImpl, - }; -} - -test("addCapability posts to the definition's workflow-run capabilities endpoint with sidecar auth", async () => { - let seenUrl: string | undefined; - let seenHeaders: Record | undefined; - let seenBody: unknown; - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seenUrl = String(url); - seenHeaders = init?.headers as Record; - seenBody = JSON.parse(String(init?.body)); - return new Response( - JSON.stringify({ - toolPackagePins: [{ name: "@corbits/example-tools" }], - skills: [], - }), - ); - }) as unknown as typeof fetch; - - const result = await addCapability(testConfig(fetchImpl), { - kind: "toolPackage", - name: "@corbits/example-tools", - }); - - expect(seenUrl).toBe("https://hub.example.com/api/workflow-capabilities/def_1/capabilities"); - expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token"); - expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); - expect(seenBody).toEqual({ - kind: "toolPackage", - name: "@corbits/example-tools", - }); - expect(result.toolPackagePins).toEqual([{ name: "@corbits/example-tools" }]); -}); - -test("addCapability throws CapabilityOutOfInventoryError on the route's fail-closed 400", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { - code: "bad_request", - userMessage: - '"@corbits/nonexistent" for "toolPackage" was never offered in this workbench\'s inventory', - refId: "ref_test", - }, - }), - { status: 400 }, - )) as unknown as typeof fetch; - - await expect( - addCapability(testConfig(fetchImpl), { - kind: "toolPackage", - name: "@corbits/nonexistent", - }), - ).rejects.toBeInstanceOf(CapabilityOutOfInventoryError); -}); - -test("addCapability throws an honest error on a non-400 HTTP failure, never fabricating a result", async () => { - const fetchImpl = (async () => - new Response("", { - status: 500, - statusText: "Internal Server Error", - })) as unknown as typeof fetch; - - await expect( - addCapability(testConfig(fetchImpl), { - kind: "skill", - name: "granola-notes", - }), - ).rejects.toThrow(/500/); -}); - -test("addCapability throws on a response that doesn't match the expected shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ nonsense: true }))) as unknown as typeof fetch; - - await expect( - addCapability(testConfig(fetchImpl), { - kind: "skill", - name: "granola-notes", - }), - ).rejects.toThrow(/expected shape/); -}); - -test("fetchCapabilityInventory flattens the inventory to plain name arrays", async () => { - const fetchImpl = (async (url: string | URL) => { - expect(String(url)).toBe("https://hub.example.com/api/workflow-capabilities/inventory"); - return new Response( - JSON.stringify({ - toolPackages: [{ name: "@corbits/memory-tools" }], - skills: [{ name: "granola-notes" }], - models: [{ canonicalName: "anthropic/claude-sonnet-5" }], - }), - ); - }) as unknown as typeof fetch; - - const inventory = await fetchCapabilityInventory(testConfig(fetchImpl)); - expect(inventory).toEqual({ - toolPackages: ["@corbits/memory-tools"], - skills: ["granola-notes"], - models: ["anthropic/claude-sonnet-5"], - }); -}); - -test("fetchCapabilityInventory throws an honest error when the hub is unreachable", async () => { - const fetchImpl = (async () => { - throw new Error("fetch failed: connection refused"); - }) as unknown as typeof fetch; - - await expect(fetchCapabilityInventory(testConfig(fetchImpl))).rejects.toThrow( - /connection refused/, - ); -}); diff --git a/tools/capability/src/client.ts b/tools/capability/src/client.ts deleted file mode 100644 index f6139f37d6..0000000000 --- a/tools/capability/src/client.ts +++ /dev/null @@ -1,130 +0,0 @@ -// A run's own workflow principal is never seeded a self-update grant, so -// this route skips the grant-store check and relies on request_capability's -// approval:"ask" gate having already put a human in front of the call. -import { type } from "arktype"; -import { - runBearerHeaders, - runBearerErrorMessage, - runBearerFetch, - type RunBearerClientConfig, -} from "./run-bearer"; - -export interface CapabilityToolClientConfig extends RunBearerClientConfig { - /** The hub's plain HTTP origin — same value memory-tools' `hubMemoryUrl` - * and skills' workflow-routes reach the hub through. */ - readonly hubCapabilitiesUrl: string; - /** The calling agent's own definition id. See the [Intx gap] note in - * `./tool.ts` — no sanctioned way exists yet for a tool execution to - * learn this on its own; it must be threaded in as part of `env`. */ - readonly definitionId: string; -} - -export type AddCapabilityRequest = - | { readonly kind: "toolPackage"; readonly name: string } - | { readonly kind: "skill"; readonly name: string } - | { readonly kind: "model"; readonly canonicalName: string }; - -export type AddedCapabilities = { - readonly toolPackagePins: readonly { readonly name: string }[]; - readonly skills: readonly string[]; - readonly model?: string; -}; - -export type CapabilityInventorySnapshot = { - readonly toolPackages: readonly string[]; - readonly skills: readonly string[]; - readonly models: readonly string[]; -}; - -/** Mirrors `CapabilityOutOfInventoryError` from - * `@corbits/agent-directory`'s `capability-inventory.ts` — the fail- - * closed 400 the route raises when the requested name was never offered - * in the tenant's live inventory. Distinguished from a bare transport/ - * HTTP error so callers can report honestly what's actually available. */ -export class CapabilityOutOfInventoryError extends Error { - constructor(message: string) { - super(message); - this.name = "CapabilityOutOfInventoryError"; - } -} - -const AddedCapabilitiesResponse = type({ - toolPackagePins: type({ name: "string" }).array(), - skills: "string[]", - "model?": "string", -}); - -const CapabilityInventoryResponse = type({ - toolPackages: type({ name: "string" }).array(), - skills: type({ name: "string" }).array(), - models: type({ canonicalName: "string" }).array(), -}); - -function endpoint(config: CapabilityToolClientConfig, path: string): string { - return `${config.hubCapabilitiesUrl}/api/workflow-capabilities/${config.definitionId}${path}`; -} - -/** Requests one capability addition. Throws - * `CapabilityOutOfInventoryError` on the route's fail-closed 400, or a - * bare `Error` on any other transport/HTTP/shape failure — never - * fabricates a success. */ -export async function addCapability( - config: CapabilityToolClientConfig, - input: AddCapabilityRequest, -): Promise { - const doFetch = runBearerFetch(config); - const response = await doFetch(endpoint(config, "/capabilities"), { - method: "POST", - headers: { - ...runBearerHeaders(config), - "content-type": "application/json", - }, - body: JSON.stringify(input), - }); - if (response.status === 400) { - const body: unknown = await response.json().catch(() => undefined); - const message = - runBearerErrorMessage(body) ?? - `"${input.kind === "model" ? input.canonicalName : input.name}" was rejected as out of inventory`; - throw new CapabilityOutOfInventoryError(message); - } - if (!response.ok) { - throw new Error(`Requesting a capability failed: ${response.status} ${response.statusText}`); - } - const body: unknown = await response.json(); - const parsed = AddedCapabilitiesResponse(body); - if (parsed instanceof type.errors) { - throw new Error(`Capability response did not match the expected shape: ${parsed.summary}`); - } - return parsed; -} - -/** Fetches the tenant's live capability inventory — used to report - * honestly what's actually available when a request is out of - * inventory. Throws on any transport, HTTP, or shape failure. */ -export async function fetchCapabilityInventory( - config: CapabilityToolClientConfig, -): Promise { - const doFetch = runBearerFetch(config); - const response = await doFetch( - `${config.hubCapabilitiesUrl}/api/workflow-capabilities/inventory`, - { headers: runBearerHeaders(config) }, - ); - if (!response.ok) { - throw new Error( - `Fetching the capability inventory failed: ${response.status} ${response.statusText}`, - ); - } - const body: unknown = await response.json(); - const parsed = CapabilityInventoryResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `Capability inventory response did not match the expected shape: ${parsed.summary}`, - ); - } - return { - toolPackages: parsed.toolPackages.map((entry) => entry.name), - skills: parsed.skills.map((entry) => entry.name), - models: parsed.models.map((entry) => entry.canonicalName), - }; -} diff --git a/tools/capability/src/index.ts b/tools/capability/src/index.ts deleted file mode 100644 index cefdd2e721..0000000000 --- a/tools/capability/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { - addCapability, - CapabilityOutOfInventoryError, - fetchCapabilityInventory, - type AddCapabilityRequest, - type AddedCapabilities, - type CapabilityInventorySnapshot, - type CapabilityToolClientConfig, -} from "./client"; -export { capabilityTools, REQUEST_CAPABILITY_TOOL, type WorkflowCapabilityEnv } from "./tool"; diff --git a/tools/capability/src/run-bearer.ts b/tools/capability/src/run-bearer.ts deleted file mode 100644 index 7acf737c9e..0000000000 --- a/tools/capability/src/run-bearer.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Kept local rather than imported from Workbench's private @corbits/workflows -// (full server dependency graph) so this bundle installs standalone in any -// Interchange-backed hub. -import { type } from "arktype"; - -export interface RunBearerClientConfig { - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; -} - -/** The two headers every run-authenticated route resolves tenant and - * principal from — identity never rides in a request body. */ -export function runBearerHeaders(config: RunBearerClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -const RunBearerErrorEnvelope = type({ - error: { code: "string", userMessage: "string" }, -}); - -/** Pulls `error.userMessage` out of the canonical hub envelope - * (`{ error: { code, userMessage } }`), if `body` matches that shape — - * `undefined` for a differently-shaped or absent body, never a throw. */ -export function runBearerErrorMessage(body: unknown): string | undefined { - const parsed = RunBearerErrorEnvelope(body); - return parsed instanceof type.errors ? undefined : parsed.error.userMessage; -} - -export function runBearerFetch(config: RunBearerClientConfig): typeof fetch { - return config.fetchImpl ?? fetch; -} diff --git a/tools/capability/src/tool.test.ts b/tools/capability/src/tool.test.ts deleted file mode 100644 index 1a7f0f2723..0000000000 --- a/tools/capability/src/tool.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { capabilityTools, REQUEST_CAPABILITY_TOOL, type WorkflowCapabilityEnv } from "./tool"; - -function testEnv(): WorkflowCapabilityEnv { - return { - hubCapabilitiesUrl: "https://hub.example.com", - sidecarToken: "sc-token", - address: "run_1@workflow", - definitionId: "def_1", - } as unknown as WorkflowCapabilityEnv; -} - -function callFor(args: Record): ToolCall { - return { id: "call_1", name: REQUEST_CAPABILITY_TOOL, arguments: args }; -} - -test("declares exactly the request_capability tool", () => { - const bundle = capabilityTools(testEnv()); - expect(bundle.definitions.map((d) => d.name)).toEqual([REQUEST_CAPABILITY_TOOL]); -}); - -test("requires the sanctioned workflow-capability env keys, including the calling agent's own definitionId", () => { - expect(capabilityTools.requires).toEqual([ - "hubCapabilitiesUrl", - "sidecarToken", - "address", - "definitionId", - ]); -}); - -test("declares approval: \"ask\" — Interchange's native per-invocation gate — so a human must approve before this bundle's run() ever executes", () => { - expect(capabilityTools.definitions).toEqual([{ name: REQUEST_CAPABILITY_TOOL, approval: "ask" }]); -}); - -test("the tool's input schema requires kind, name, and why, and offers an optional title for the approval card", () => { - const bundle = capabilityTools(testEnv()); - const definition = bundle.definitions[0] as unknown as { - inputSchema: { required: string[]; properties: Record }; - }; - expect(definition.inputSchema.required).toEqual(["kind", "name", "why"]); - expect(definition.inputSchema.properties["title"]).toBeDefined(); -}); - -test("the tool's description reads as a humane approval-card headline", () => { - const bundle = capabilityTools(testEnv()); - const definition = bundle.definitions[0] as unknown as { - description: string; - }; - expect(definition.description).toMatch(/^add a capability/); -}); - -test("rejects a call missing a required field without calling out", async () => { - const bundle = capabilityTools(testEnv()); - const result = await bundle.run( - callFor({ kind: "tool-package", name: "@corbits/example-tools" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/invalid input/); -}); - -test("rejects an unknown kind without calling out", async () => { - const bundle = capabilityTools(testEnv()); - const result = await bundle.run( - callFor({ kind: "database", name: "prod", why: "need it" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); -}); - -test("on approval, calls the capabilities route with the calling agent's own definitionId and returns a plain success message", async () => { - let seenUrl: string | undefined; - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url: string | URL) => { - seenUrl = String(url); - return new Response( - JSON.stringify({ - toolPackagePins: [{ name: "@corbits/example-tools" }], - skills: [], - }), - ); - }) as unknown as typeof fetch; - try { - const bundle = capabilityTools(testEnv()); - const result = await bundle.run( - callFor({ - kind: "tool-package", - name: "@corbits/example-tools", - why: "I need it", - title: "Example tools", - }), - new AbortController().signal, - ); - expect(seenUrl).toBe("https://hub.example.com/api/workflow-capabilities/def_1/capabilities"); - expect(result.isError).toBeFalsy(); - expect(result.content).toBe("Added @corbits/example-tools — I can use it from my next reply."); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("maps a model request's name to canonicalName in the request body", async () => { - let seenBody: unknown; - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { - seenBody = JSON.parse(String(init?.body)); - return new Response(JSON.stringify({ toolPackagePins: [], skills: [] })); - }) as unknown as typeof fetch; - try { - const bundle = capabilityTools(testEnv()); - await bundle.run( - callFor({ - kind: "model", - name: "anthropic/claude-sonnet-5", - why: "faster replies", - }), - new AbortController().signal, - ); - expect(seenBody).toEqual({ - kind: "model", - canonicalName: "anthropic/claude-sonnet-5", - }); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("an out-of-inventory request reports what's actually available, never a fabricated success", async () => { - const originalFetch = globalThis.fetch; - let call = 0; - globalThis.fetch = (async (url: string | URL) => { - call += 1; - if (call === 1) { - return new Response( - JSON.stringify({ - error: { - code: "bad_request", - userMessage: "out of inventory", - refId: "ref_test", - }, - }), - { status: 400 }, - ); - } - expect(String(url)).toBe("https://hub.example.com/api/workflow-capabilities/inventory"); - return new Response( - JSON.stringify({ - toolPackages: [{ name: "@corbits/memory-tools" }], - skills: [], - models: [], - }), - ); - }) as unknown as typeof fetch; - try { - const bundle = capabilityTools(testEnv()); - const result = await bundle.run( - callFor({ - kind: "tool-package", - name: "@corbits/nonexistent", - why: "I want it", - }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/isn't available/); - expect(result.content).toMatch(/@corbits\/memory-tools/); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("falls back to the route's own message if the inventory itself can't be fetched after an out-of-inventory rejection", async () => { - const originalFetch = globalThis.fetch; - let call = 0; - globalThis.fetch = (async () => { - call += 1; - if (call === 1) { - return new Response( - JSON.stringify({ - error: { - code: "bad_request", - userMessage: "nothing named that", - refId: "ref_test", - }, - }), - { status: 400 }, - ); - } - throw new Error("hub unreachable"); - }) as unknown as typeof fetch; - try { - const bundle = capabilityTools(testEnv()); - const result = await bundle.run( - callFor({ - kind: "skill", - name: "nonexistent-skill", - why: "I want it", - }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toBe("nothing named that"); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("returns an honest error result on an unreachable hub, never fabricating success", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => { - throw new Error("fetch failed: connection refused"); - }) as unknown as typeof fetch; - try { - const bundle = capabilityTools(testEnv()); - const result = await bundle.run( - callFor({ kind: "tool-package", name: "@corbits/x", why: "why" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/connection refused/); - } finally { - globalThis.fetch = originalFetch; - } -}); - -test("an unknown tool name returns an honest error, never a silent no-op", async () => { - const bundle = capabilityTools(testEnv()); - const result = await bundle.run( - { id: "call_1", name: "delete_everything", arguments: {} }, - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/unknown tool/); -}); diff --git a/tools/capability/src/tool.ts b/tools/capability/src/tool.ts deleted file mode 100644 index 727f151318..0000000000 --- a/tools/capability/src/tool.ts +++ /dev/null @@ -1,193 +0,0 @@ -// `request_capability` is declared `approval: "ask"`, so the reactor -// suspends it as a pending approval before `run` ever executes; this -// bundle never sees or controls that gate. `definitionId` is threaded onto -// `env` by the sidecar's per-step env builder, the same ground -// @corbits/memory-tools' env keys come from. See ./client.ts for the -// workflow-run-authenticated route this bundle's execution calls. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { type } from "arktype"; - -import { - addCapability, - CapabilityOutOfInventoryError, - fetchCapabilityInventory, - type AddCapabilityRequest, -} from "./client"; - -export const REQUEST_CAPABILITY_TOOL = "request_capability"; - -/** Env this bundle needs beyond `BaseEnv`: the run's hub-reach - * credential, mirroring `@corbits/memory-tools`' `WorkflowMemoryEnv`, - * plus the calling agent's own definition id (see the [Intx gap] note - * above — not threaded by the sidecar yet). */ -export interface WorkflowCapabilityEnv extends BaseEnv { - readonly hubCapabilitiesUrl: string; - readonly sidecarToken: string; - readonly address: string; - readonly definitionId: string; -} - -const RequestCapabilityInput = type({ - kind: "'tool-package'|'skill'|'model'", - name: "string > 0", - why: "string > 0", - "title?": "string > 0", -}); -type RequestCapabilityInput = typeof RequestCapabilityInput.infer; - -function errorResult(callId: string, err: unknown): ToolResult { - return { - callId, - isError: true, - content: err instanceof Error ? err.message : String(err), - }; -} - -function clientConfig(env: WorkflowCapabilityEnv) { - return { - hubCapabilitiesUrl: env.hubCapabilitiesUrl, - sidecarToken: env.sidecarToken, - address: env.address, - definitionId: env.definitionId, - }; -} - -function toAddCapabilityRequest(input: RequestCapabilityInput): AddCapabilityRequest { - switch (input.kind) { - case "tool-package": - return { kind: "toolPackage", name: input.name }; - case "skill": - return { kind: "skill", name: input.name }; - case "model": - return { kind: "model", canonicalName: input.name }; - } -} - -const KIND_LABEL: Record = { - "tool-package": "tool packages", - skill: "skills", - model: "models", -}; - -/** Builds the honest "what's actually available" message an - * out-of-inventory rejection gets, from the same inventory the route - * just checked against — never a stale or guessed list. */ -function outOfInventoryMessage( - input: RequestCapabilityInput, - inventory: { - toolPackages: readonly string[]; - skills: readonly string[]; - models: readonly string[]; - }, -): string { - const available = - input.kind === "tool-package" - ? inventory.toolPackages - : input.kind === "skill" - ? inventory.skills - : inventory.models; - const label = KIND_LABEL[input.kind]; - return available.length === 0 - ? `"${input.name}" isn't available, and no ${label} are offered right now.` - : `"${input.name}" isn't available. Available ${label}: ${available.join(", ")}.`; -} - -async function runRequestCapability( - env: WorkflowCapabilityEnv, - call: ToolCall, -): Promise { - const parsed = RequestCapabilityInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`request_capability received invalid input: ${parsed.summary}`), - ); - } - - try { - await addCapability(clientConfig(env), toAddCapabilityRequest(parsed)); - return { - callId: call.id, - isError: false, - content: `Added ${parsed.name} — I can use it from my next reply.`, - }; - } catch (err) { - if (err instanceof CapabilityOutOfInventoryError) { - try { - const inventory = await fetchCapabilityInventory(clientConfig(env)); - return errorResult(call.id, new Error(outOfInventoryMessage(parsed, inventory))); - } catch { - return errorResult(call.id, err); - } - } - return errorResult(call.id, err); - } -} - -/** `description` is written to read as an approval-card headline, which appends `arguments.title` verbatim when supplied. */ -export const capabilityTools = defineTool({ - id: "@corbits/capability-tools/cap", - requires: ["hubCapabilitiesUrl", "sidecarToken", "address", "definitionId"], - definitions: [{ name: REQUEST_CAPABILITY_TOOL, approval: "ask" }], - factory: (env) => ({ - definitions: [ - { - name: REQUEST_CAPABILITY_TOOL, - description: - "add a capability it doesn't have yet — a tool package, a " + - "skill, or a model. Use this only when a genuine, specific " + - "need comes up in conversation; never request a capability " + - "speculatively. A human must approve before anything is " + - "added, and the request is checked against what the " + - "workspace actually offers — an unavailable request comes " + - "back naming what's available instead.", - inputSchema: { - type: "object", - properties: { - kind: { - type: "string", - enum: ["tool-package", "skill", "model"], - description: "What kind of capability this is.", - }, - name: { - type: "string", - description: - "The capability's exact name as offered in the " + - "workspace's inventory (a tool package or skill name, " + - "or a model's canonical name) — never invented.", - }, - why: { - type: "string", - description: - "One sentence on why this agent needs it right now, " + - "shown to the person approving the request.", - }, - title: { - type: "string", - description: - "Optional short human-friendly label for the " + - 'capability (e.g. "GitHub tools"), shown in the ' + - "approval card.", - }, - }, - required: ["kind", "name", "why"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - switch (call.name) { - case REQUEST_CAPABILITY_TOOL: - return runRequestCapability(env, call); - default: - return Promise.resolve( - errorResult( - call.id, - new Error(`@corbits/capability-tools: unknown tool "${call.name}"`), - ), - ); - } - }, - }), -}); diff --git a/tools/capability/tsconfig.json b/tools/capability/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/capability/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/capability/tsconfig.src.json b/tools/capability/tsconfig.src.json deleted file mode 100644 index d84257c8f8..0000000000 --- a/tools/capability/tsconfig.src.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true, - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/catalog/LICENSE b/tools/catalog/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/catalog/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/catalog/README.md b/tools/catalog/README.md deleted file mode 100644 index 841af15ae9..0000000000 --- a/tools/catalog/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# @corbits/catalog-tools - -Three read-only tools that let an agent choose a model without ever naming -one. - -- **`list_model_concepts`** — the kinds of work this workbench can pick a - model for, and how many models it currently has for each. -- **`pick_models`** — the models this workbench can actually reach for a kind - of work, cheapest first, with fallbacks behind the head. -- **`estimate_run_cost`** — what a run would cost on those models, or an - honest "no price on record" when a model is unpriced. - -A model named from memory is a guess about a bench the agent cannot see. A -kind of work is a question this bench can answer from its own connected -providers, capability data, and prices — which is why `pick_models` takes a -concept or a capability set and refuses both at once, and never takes a model -name at all. - -Everything comes from `@corbits/inference-catalog`'s run-authenticated -surface at `/api/workflow-inference-catalog`, reached with the sidecar bearer -token and run address like every other workflow-run tool bundle. All three -tools read only, so none is gated behind approval. - -## Managing offerings - -Catalog administration is UI-only: models, providers, and -offerings are managed through the tenant-admin catalog routes -(`vendor/intx/hub-api/src/routes/{models,model-providers, -model-offerings}.ts`) behind a browser session. This package exposes no -write tools and no hub routes — the three read-only tools above are the -whole surface. diff --git a/tools/catalog/package.json b/tools/catalog/package.json deleted file mode 100644 index c5fe3a9b7c..0000000000 --- a/tools/catalog/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@corbits/catalog-tools", - "version": "0.1.1", - "description": "The list_model_concepts, pick_models and estimate_run_cost tools as an @intx/agent bundle: an agent asks for a model by what the work needs and gets back an ordered, priced chain of what this bench can actually reach — never a model named by hand.", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/catalog" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/inference-catalog": "0.1.0", - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - } -} diff --git a/tools/catalog/src/chain.ts b/tools/catalog/src/chain.ts deleted file mode 100644 index f38c5cda07..0000000000 --- a/tools/catalog/src/chain.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Chain resolution, in the workflow child. -// -// The two stock reads happen once per tool call and are then used for every -// question that call asks — `list_model_concepts` ranks every concept off -// one catalog read rather than one read per concept, which is what the -// deleted hub route did per request. -import { - catalogOfferingsFrom, - resolveModelChain, - type BenchModelPolicy, - type ChainNeed, - type ChainOrder, - type DiscoveredModel, - type ModelChain, -} from "@corbits/inference-catalog"; - -import { fetchCatalog, fetchModelPolicy, type CatalogToolClientConfig } from "./client"; - -/** What this bench can reach and what it is willing to spend, read once. */ -export type BenchCatalog = { - readonly models: readonly DiscoveredModel[]; - readonly policy: BenchModelPolicy; -}; - -export async function readBenchCatalog(config: CatalogToolClientConfig): Promise { - const [models, policy] = await Promise.all([fetchCatalog(config), fetchModelPolicy(config)]); - return { models, policy }; -} - -export function chainFor( - bench: BenchCatalog, - need: ChainNeed, - order?: ChainOrder, - limit?: number, -): ModelChain { - const { offerings, pricing } = catalogOfferingsFrom(bench.models); - return resolveModelChain({ - need, - offerings, - pricing, - policy: bench.policy, - ...(order !== undefined ? { order } : {}), - ...(limit !== undefined ? { limit } : {}), - }); -} - -/** The one line that explains an otherwise puzzling chain: empty, entirely - * unpriced, or partly over this bench's ceiling. */ -export function chainNote(chain: ModelChain): string | null { - if (chain.entries.length === 0) return "nothing on this bench can do that"; - if (chain.entries.every((entry) => !entry.price.known)) { - return "nothing on this bench is priced yet, so cheapest-first is ordered by catalog priority"; - } - if (chain.entries.some((entry) => entry.overCeiling)) { - return "some of these cost more than this bench's ceiling for that kind of work"; - } - return null; -} diff --git a/tools/catalog/src/client.test.ts b/tools/catalog/src/client.test.ts deleted file mode 100644 index 481cff78ce..0000000000 --- a/tools/catalog/src/client.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { fetchCatalog, fetchModelPolicy, type CatalogToolClientConfig } from "./client"; - -const MODELS_BODY = [ - { - id: "m1", - canonicalName: "thrifty", - displayName: "Thrifty", - offerings: [ - { - offeringId: "off_1", - providerName: "globex", - plugin: "openai-compatible", - priority: 0, - capabilities: ["plain-text"], - pricing: [ - { - offeringId: "off_1", - currency: "USD", - inputTokenPrice: "0.0000001", - outputTokenPrice: "0.0000004", - }, - ], - }, - ], - }, -]; - -function config(fetchImpl: typeof fetch): CatalogToolClientConfig { - return { - hubCatalogUrl: "https://hub.example.com", - tenantId: "bench_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - }; -} - -describe("catalog tool client", () => { - test("carries the sidecar token and run address on every call", async () => { - let seen: Request | undefined; - const fetchImpl = (async (input: string, init?: RequestInit) => { - seen = new Request(input, init); - return new Response(JSON.stringify(MODELS_BODY), { status: 200 }); - }) as unknown as typeof fetch; - - await fetchCatalog(config(fetchImpl)); - - expect(seen?.url).toBe("https://hub.example.com/api/tenants/bench_1/models"); - expect(seen?.headers.get("authorization")).toBe("Bearer sc-token"); - expect(seen?.headers.get("x-workflow-run-address")).toBe("run_1@workflow"); - }); - - test("a refusal from the hub surfaces its own message", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { - code: "bad_request", - userMessage: "the bench is not reachable", - refId: "ref_test", - }, - }), - { status: 400 }, - )) as unknown as typeof fetch; - - await expect(fetchCatalog(config(fetchImpl))).rejects.toThrow("the bench is not reachable"); - }); - - test("a body in an unexpected shape is an error, not a half-parsed answer", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ not: "a model list" }), { - status: 200, - })) as unknown as typeof fetch; - - await expect(fetchCatalog(config(fetchImpl))).rejects.toThrow("unexpected shape"); - }); - - test("an unreachable hub is an error, never an empty list", async () => { - const fetchImpl = (async () => { - throw new Error("connection refused"); - }) as unknown as typeof fetch; - - await expect(fetchCatalog(config(fetchImpl))).rejects.toThrow("connection refused"); - }); - - test("reads the model policy out of the tenant's own config blob", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - id: "bench_1", - config: { corbits: { modelPolicy: { deny: ["acme/alpha"] } } }, - }), - { status: 200 }, - )) as unknown as typeof fetch; - - const policy = await fetchModelPolicy(config(fetchImpl)); - expect(policy.deny).toEqual(["acme/alpha"]); - }); - - test("a bench with no config blob reads as the empty policy", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ id: "bench_1" }), { - status: 200, - })) as unknown as typeof fetch; - - const policy = await fetchModelPolicy(config(fetchImpl)); - expect(policy.allow).toEqual([]); - expect(policy.deny).toEqual([]); - }); -}); diff --git a/tools/catalog/src/client.ts b/tools/catalog/src/client.ts deleted file mode 100644 index ea59864ac6..0000000000 --- a/tools/catalog/src/client.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Both reads are stock Interchange tenant routes; no hub-side chain -// resolution or Workbench-specific mirror any more — the chain is computed -// here, in the workflow child, from the models list and the tenant's -// corbits.modelPolicy config blob. -import { type } from "arktype"; -import { DiscoveredModel } from "@corbits/inference-catalog/catalog"; -import { readModelPolicy, type BenchModelPolicy } from "@corbits/inference-catalog/policy"; - -export interface CatalogToolClientConfig { - /** The hub's plain HTTP origin — the same value connections-tools' - * `hubConnectionsUrl` and memory-tools' `hubMemoryUrl` reach the hub - * through. */ - readonly hubCatalogUrl: string; - /** The run's own tenant — the `:tenantId` segment of a stock route. */ - readonly tenantId: string; - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; -} - -const DiscoveredModels = DiscoveredModel.array(); - -const TenantResponse = type({ - id: "string", - "config?": type({ "[string]": "unknown" }).or("null"), -}); - -function authHeaders(config: CatalogToolClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -/** Pulls `error.userMessage` or `error.message` out of the hub's error - * envelope, if `body` matches that shape. */ -function errorMessageFrom(body: unknown): string | undefined { - if (body === null || typeof body !== "object" || !("error" in body)) { - return undefined; - } - const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object") return undefined; - const fields = error as { userMessage?: unknown; message?: unknown }; - if (typeof fields.userMessage === "string") return fields.userMessage; - return typeof fields.message === "string" ? fields.message : undefined; -} - -async function read( - config: CatalogToolClientConfig, - path: string, - what: string, - schema: (body: unknown) => T | type.errors, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch( - `${config.hubCatalogUrl}/api/tenants/${encodeURIComponent(config.tenantId)}${path}`, - { headers: authHeaders(config) }, - ); - if (!response.ok) { - const detail: unknown = await response.json().catch(() => undefined); - const message = errorMessageFrom(detail) ?? `${response.status} ${response.statusText}`; - throw new Error(`${what} failed: ${message}`); - } - const parsed = schema(await response.json()); - if (parsed instanceof type.errors) { - throw new Error(`${what} came back in an unexpected shape: ${parsed.summary}`); - } - return parsed; -} - -/** This bench's resolved model catalog. Throws on any transport, HTTP, or - * shape failure — never fabricates a catalog. */ -export async function fetchCatalog( - config: CatalogToolClientConfig, -): Promise { - return await read(config, "/models", "Reading this bench's models", (raw) => - DiscoveredModels(raw), - ); -} - -/** This bench's model policy, out of its own tenant config blob. */ -export async function fetchModelPolicy(config: CatalogToolClientConfig): Promise { - const tenant = await read(config, "", "Reading this bench's settings", (raw) => - TenantResponse(raw), - ); - return readModelPolicy(tenant.config ?? {}); -} diff --git a/tools/catalog/src/index.ts b/tools/catalog/src/index.ts deleted file mode 100644 index a9fbae9776..0000000000 --- a/tools/catalog/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { chainFor, chainNote, readBenchCatalog, type BenchCatalog } from "./chain"; -export { fetchCatalog, fetchModelPolicy, type CatalogToolClientConfig } from "./client"; -export { - catalogTools, - describeChain, - ESTIMATE_RUN_COST_TOOL, - LIST_MODEL_CONCEPTS_TOOL, - PICK_MODELS_TOOL, - type WorkflowCatalogEnv, -} from "./tool"; -// The three read-only tools resolve the chain in the workflow child from -// two stock Interchange tenant reads — `/api/tenants/:tenantId/models` and -// `/api/tenants/:tenantId` — with the run's own bearer credential. There is -// no `/api/workflow-inference-catalog` mount any more. Catalog -// administration stays UI-only: this package exposes no write -// tools and no hub routes. diff --git a/tools/catalog/src/tool.test.ts b/tools/catalog/src/tool.test.ts deleted file mode 100644 index 1feda6cc41..0000000000 --- a/tools/catalog/src/tool.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { - catalogTools, - ESTIMATE_RUN_COST_TOOL, - LIST_MODEL_CONCEPTS_TOOL, - PICK_MODELS_TOOL, - type WorkflowCatalogEnv, -} from "./tool"; - -const CHEAP_LOOP_MODELS = [ - { - id: "m1", - canonicalName: "thrifty", - displayName: "Thrifty", - offerings: [ - { - offeringId: "off_1", - providerName: "globex", - plugin: "openai-compatible", - priority: 0, - capabilities: ["plain-text"], - pricing: [ - { - offeringId: "off_1", - currency: "USD", - inputTokenPrice: "0.0000001", - outputTokenPrice: "0.0000004", - }, - ], - }, - ], - }, - { - id: "m2", - canonicalName: "mystery", - displayName: null, - offerings: [ - { - offeringId: "off_2", - providerName: "initech", - plugin: "openai-compatible", - priority: 1, - capabilities: ["plain-text"], - pricing: [], - }, - ], - }, -]; - -const IMAGE_MAKER_MODELS = [ - { - id: "m1", - canonicalName: "thrifty", - displayName: "Thrifty", - offerings: [ - { - offeringId: "off_1", - providerName: "globex", - plugin: "openai-compatible", - priority: 0, - capabilities: ["plain-text"], - pricing: [], - }, - ], - }, -]; - -function env(): WorkflowCatalogEnv { - return { - hubCatalogUrl: "https://hub.example.com", - tenantId: "bench_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - } as unknown as WorkflowCatalogEnv; -} - -function callFor(name: string, args: Record): ToolCall { - return { id: "call_1", name, arguments: args }; -} - -/** Stubs both stock reads `readBenchCatalog` makes: the model list at - * `/models`, and the tenant itself (for its config's model policy). */ -function stubbing(models: unknown, config?: Record): void { - globalThis.fetch = (async (input: string) => { - const url = String(input); - if (url.endsWith("/models")) { - return new Response(JSON.stringify(models), { status: 200 }); - } - return new Response(JSON.stringify({ id: "bench_1", config: config ?? null }), { status: 200 }); - }) as unknown as typeof fetch; -} - -describe("pick_models", () => { - test("naming both a concept and capabilities is refused, and says why", async () => { - const result = await catalogTools(env()).run( - callFor(PICK_MODELS_TOOL, { - concept: "cheap-loop", - capabilities: ["plain-text"], - }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toContain("exactly one"); - }); - - test("naming neither is refused too", async () => { - const result = await catalogTools(env()).run( - callFor(PICK_MODELS_TOOL, {}), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - }); - - test("a capability outside the vocabulary never reaches the hub", async () => { - const result = await catalogTools(env()).run( - callFor(PICK_MODELS_TOOL, { capabilities: ["telepathy"] }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - }); - - test("names every model in the chain, in order, and no model that is not", async () => { - stubbing(CHEAP_LOOP_MODELS); - const result = await catalogTools(env()).run( - callFor(PICK_MODELS_TOOL, { concept: "cheap-loop" }), - new AbortController().signal, - ); - expect(result.isError).toBe(false); - const content = String(result.content); - expect(content.indexOf("Thrifty")).toBeLessThan(content.indexOf("mystery")); - expect(content).toContain("fallbacks"); - expect(content).not.toContain("claude"); - expect(content).not.toContain("gpt"); - }); - - test("an unpriced model is reported as unpriced, never as free", async () => { - stubbing(CHEAP_LOOP_MODELS); - const result = await catalogTools(env()).run( - callFor(PICK_MODELS_TOOL, { concept: "cheap-loop" }), - new AbortController().signal, - ); - expect(String(result.content)).toContain("no price on record"); - expect(String(result.content)).not.toContain("$0.00 in"); - }); - - test("an empty chain says nothing here can do it, without inventing a model", async () => { - stubbing(IMAGE_MAKER_MODELS); - const result = await catalogTools(env()).run( - callFor(PICK_MODELS_TOOL, { concept: "image-maker" }), - new AbortController().signal, - ); - expect(result.isError).toBe(false); - expect(result.content).toBe("nothing on this bench can do that"); - }); -}); - -describe("estimate_run_cost", () => { - test("reports an honest no-estimate for an unpriced model", async () => { - stubbing(CHEAP_LOOP_MODELS); - const result = await catalogTools(env()).run( - callFor(ESTIMATE_RUN_COST_TOOL, { - concept: "cheap-loop", - expectedInputTokens: 1000, - expectedOutputTokens: 100, - }), - new AbortController().signal, - ); - expect(String(result.content)).toContain("no price on record"); - }); - - test("token counts are required", async () => { - const result = await catalogTools(env()).run( - callFor(ESTIMATE_RUN_COST_TOOL, { concept: "cheap-loop" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - }); -}); - -describe("list_model_concepts", () => { - test("says how many models this bench has for each kind of work", async () => { - stubbing(CHEAP_LOOP_MODELS); - const result = await catalogTools(env()).run( - callFor(LIST_MODEL_CONCEPTS_TOOL, {}), - new AbortController().signal, - ); - const content = String(result.content); - expect(content).toContain("cheap-loop"); - expect(content).toContain("2 models here, best via globex"); - expect(content).toContain("nothing here can do it yet"); - }); -}); -// Catalog administration is UI-only: no write tools, so no -// write-tool tests — the read-only describes above are the whole suite. diff --git a/tools/catalog/src/tool.ts b/tools/catalog/src/tool.ts deleted file mode 100644 index 92709ab12f..0000000000 --- a/tools/catalog/src/tool.ts +++ /dev/null @@ -1,309 +0,0 @@ -// Enforces asking for a model by what the work needs, never by name — a -// named model is a guess about a bench the agent can't see. Every answer is -// an ordered chain with real or unknown prices, never an invented model or -// a zero price. All three tools are read-only (no approval key); the chain -// is resolved here from two stock tenant reads (see client.ts), not a -// Workbench-specific hub route. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { WIRE_CAPABILITIES, type Capability } from "@intx/types"; -import { type } from "arktype"; - -import { - CONCEPTS, - estimateUsd, - type ChainEntry, - type ChainNeed, - type ModelChain, -} from "@corbits/inference-catalog"; - -import { chainFor, chainNote, readBenchCatalog } from "./chain"; -import type { CatalogToolClientConfig } from "./client"; - -export const LIST_MODEL_CONCEPTS_TOOL = "list_model_concepts"; -export const PICK_MODELS_TOOL = "pick_models"; -export const ESTIMATE_RUN_COST_TOOL = "estimate_run_cost"; - -/** Env this bundle needs beyond `BaseEnv`: the run's hub-reach credential. */ -export interface WorkflowCatalogEnv extends BaseEnv { - readonly hubCatalogUrl: string; - /** The run's own tenant — the `:tenantId` segment of a stock route. */ - readonly tenantId: string; - readonly sidecarToken: string; - readonly address: string; -} - -const NEED_MESSAGE = - "ask by either `concept` (a kind of work) or `capabilities` (the features the work needs) — exactly one, never both, and never a model name"; - -const NeedInput = type({ - "concept?": "string > 0", - "capabilities?": type.enumerated(...WIRE_CAPABILITIES).array(), -}).narrow((need, ctx) => { - const hasConcept = need.concept !== undefined; - const hasCapabilities = need.capabilities !== undefined; - return hasConcept === hasCapabilities ? ctx.mustBe(NEED_MESSAGE) : true; -}); - -const PickModelsInput = NeedInput.and({ - "order?": "'cheapest'|'catalog'", - "limit?": "1 <= number <= 10", -}); -type PickModelsInput = typeof PickModelsInput.infer; - -const EstimateRunCostInput = NeedInput.and({ - expectedInputTokens: "number >= 0", - expectedOutputTokens: "number >= 0", -}); -type EstimateRunCostInput = typeof EstimateRunCostInput.infer; - -const needProperties = { - concept: { - type: "string", - description: - "The kind of work, as an id from list_model_concepts (e.g. " + - '"cheap-loop", "code-work"). Never a model name.', - }, - capabilities: { - type: "array", - items: { type: "string", enum: [...WIRE_CAPABILITIES] }, - description: - "The features the work needs on the wire. Use this instead of " + - "concept when no kind of work fits.", - }, -} as const; - -function errorResult(callId: string, err: unknown): ToolResult { - return { - callId, - isError: true, - content: err instanceof Error ? err.message : String(err), - }; -} - -function clientConfig(env: WorkflowCatalogEnv): CatalogToolClientConfig { - return { - hubCatalogUrl: env.hubCatalogUrl, - tenantId: env.tenantId, - sidecarToken: env.sidecarToken, - address: env.address, - }; -} - -/** The need, as exactly one of the two forms chain resolution accepts. The - * input schema has already rejected both-at-once and neither. */ -function needOf(input: { concept?: string; capabilities?: Capability[] }): ChainNeed { - return input.concept !== undefined - ? { concept: input.concept } - : { capabilities: input.capabilities ?? [] }; -} - -function usd(amount: number): string { - return amount >= 0.01 ? `$${amount.toFixed(2)}` : `$${amount.toFixed(4)}`; -} - -function describeEntry(entry: ChainEntry, position: number): string { - const name = entry.displayName ?? entry.canonicalName; - const price = entry.price.known - ? `${usd(entry.price.inputUsdPerMTok ?? 0)} in / ${usd(entry.price.outputUsdPerMTok ?? 0)} out per 1M tokens` - : "no price on record"; - const flag = entry.overCeiling ? " — over this bench's ceiling" : ""; - return `${position}. ${name} via ${entry.providerName} — ${price}${flag}`; -} - -export function describeChain(chain: ModelChain): string { - const note = chainNote(chain); - if (chain.entries.length === 0) { - return note ?? "Nothing on this bench can do that kind of work right now."; - } - const lines = chain.entries.map((entry, index) => describeEntry(entry, index + 1)); - const head = - chain.entries.length === 1 - ? "One model here fits:" - : "Use the first; the rest are fallbacks, in order:"; - return [head, ...lines, note].filter(Boolean).join("\n"); -} - -async function runListModelConcepts(env: WorkflowCatalogEnv, call: ToolCall): Promise { - try { - const bench = await readBenchCatalog(clientConfig(env)); - const lines = CONCEPTS.map((concept) => { - const chain = chainFor(bench, { concept: concept.id }, "cheapest", 10); - const head = chain.entries[0]; - const available = chain.entries.length; - const availability = - available === 0 - ? "nothing here can do it yet" - : `${available} model${available === 1 ? "" : "s"} here, best via ${head?.providerName ?? "unknown"}`; - return `${concept.id} — ${concept.whenToUse} (${availability})`; - }); - return { - callId: call.id, - isError: false, - content: ["Ask for a model by the kind of work, using one of these ids:", ...lines].join( - "\n", - ), - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runPickModels( - env: WorkflowCatalogEnv, - call: ToolCall, - parsed: PickModelsInput, -): Promise { - try { - const bench = await readBenchCatalog(clientConfig(env)); - const chain = chainFor(bench, needOf(parsed), parsed.order, parsed.limit); - return { callId: call.id, isError: false, content: describeChain(chain) }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runEstimateRunCost( - env: WorkflowCatalogEnv, - call: ToolCall, - parsed: EstimateRunCostInput, -): Promise { - try { - const bench = await readBenchCatalog(clientConfig(env)); - const chain = chainFor(bench, needOf(parsed), "cheapest", 10); - if (chain.entries.length === 0) { - return { - callId: call.id, - isError: false, - content: "Nothing on this bench can do that kind of work right now.", - }; - } - const lines = chain.entries.map((entry) => { - const amount = estimateUsd( - entry.price, - parsed.expectedInputTokens, - parsed.expectedOutputTokens, - ); - return amount === null - ? `${entry.canonicalName} via ${entry.providerName} — no price on record, so no estimate` - : `${entry.canonicalName} via ${entry.providerName} — about ${usd(amount)}`; - }); - return { - callId: call.id, - isError: false, - content: ["That run would cost roughly:", ...lines].join("\n"), - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -/** - * The `@corbits/catalog-tools` bundle factory: three read-only tools, no - * approval gate, three env keys. - */ -export const catalogTools = defineTool({ - id: "@corbits/catalog-tools/catalog", - requires: ["hubCatalogUrl", "tenantId", "sidecarToken", "address"], - definitions: [ - { name: LIST_MODEL_CONCEPTS_TOOL }, - { name: PICK_MODELS_TOOL }, - { name: ESTIMATE_RUN_COST_TOOL }, - ], - factory: (env) => ({ - definitions: [ - { - name: LIST_MODEL_CONCEPTS_TOOL, - description: - "see the kinds of work this workbench can pick a model for, " + - "and how many models it currently has for each. Read-only; " + - "call it before pick_models when unsure which kind of work " + - "fits.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: PICK_MODELS_TOOL, - description: - "get the models this workbench can actually reach for a kind " + - "of work, cheapest first, with fallbacks behind the first. " + - "Never name a model — ask by what the work needs.", - inputSchema: { - type: "object", - properties: { - ...needProperties, - order: { - type: "string", - enum: ["cheapest", "catalog"], - description: - 'Default "cheapest". Use "catalog" to follow this ' + - "workbench's own preferred order instead of price.", - }, - limit: { - type: "number", - description: "How many models to return, 1-10. Default 5.", - }, - }, - }, - }, - { - name: ESTIMATE_RUN_COST_TOOL, - description: - "estimate what a run would cost on the models this workbench " + - "would use for a kind of work. Answers honestly that a model " + - "has no price on record rather than guessing one.", - inputSchema: { - type: "object", - properties: { - ...needProperties, - expectedInputTokens: { - type: "number", - description: "Roughly how many tokens will be sent.", - }, - expectedOutputTokens: { - type: "number", - description: "Roughly how many tokens will come back.", - }, - }, - required: ["expectedInputTokens", "expectedOutputTokens"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - switch (call.name) { - case LIST_MODEL_CONCEPTS_TOOL: - return runListModelConcepts(env, call); - case PICK_MODELS_TOOL: { - const parsed = PickModelsInput(call.arguments); - if (parsed instanceof type.errors) { - return Promise.resolve( - errorResult( - call.id, - new Error(`pick_models received invalid input: ${parsed.summary}`), - ), - ); - } - return runPickModels(env, call, parsed); - } - case ESTIMATE_RUN_COST_TOOL: { - const parsed = EstimateRunCostInput(call.arguments); - if (parsed instanceof type.errors) { - return Promise.resolve( - errorResult( - call.id, - new Error(`estimate_run_cost received invalid input: ${parsed.summary}`), - ), - ); - } - return runEstimateRunCost(env, call, parsed); - } - default: - return Promise.resolve( - errorResult(call.id, new Error(`@corbits/catalog-tools: unknown tool "${call.name}"`)), - ); - } - }, - }), -}); -// Catalog administration is UI-only: this package exposes no -// write tools — the three read-only tools above are the whole bundle. diff --git a/tools/catalog/tsconfig.json b/tools/catalog/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/catalog/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/catalog/tsconfig.src.json b/tools/catalog/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/catalog/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/interaction/LICENSE b/tools/interaction/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/interaction/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/interaction/README.md b/tools/interaction/README.md deleted file mode 100644 index edbb239647..0000000000 --- a/tools/interaction/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# @corbits/interaction-tools - -The `ask_user` tool: an `@intx/agent` bundle that poses an interview -question in-thread as an interactive question block, then ends the turn. -A Workbench agent treats "ask a person" as native — post the question -and stop — rather than a structural park: the person's answer arrives as -the agent's next inbound message, not as this call's result. - -See `./src/tool.ts` for the tool definition and `./src/client.ts` for -the hub call that posts the question block. diff --git a/tools/interaction/package.json b/tools/interaction/package.json deleted file mode 100644 index 9acdb13af1..0000000000 --- a/tools/interaction/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@corbits/interaction-tools", - "version": "0.0.11", - "description": "The ask_user tool: an @intx/agent bundle that poses an interview question in-thread as a question block, then ends the turn — the user's answer arrives as the agent's next message, not this call's result", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/interaction" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@corbits/error-sink": "0.0.2", - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - } -} diff --git a/tools/interaction/src/client.test.ts b/tools/interaction/src/client.test.ts deleted file mode 100644 index e4143da04e..0000000000 --- a/tools/interaction/src/client.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { expect, test } from "bun:test"; - -import { NoOwnChannelError, postQuestion } from "./client"; -import type { AskUserClientConfig } from "./client"; - -function testConfig(fetchImpl: typeof fetch): AskUserClientConfig { - return { - hubChatUrl: "https://hub.example.com", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - }; -} - -test("postQuestion posts a question block to the workflow-chat participants/messages endpoint with sidecar auth", async () => { - let seenUrl: string | undefined; - let seenHeaders: Record | undefined; - let seenBody: unknown; - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seenUrl = String(url); - seenHeaders = init?.headers as Record; - seenBody = JSON.parse(String(init?.body)); - return new Response( - JSON.stringify({ - id: "msg_1", - createdAt: "2026-08-17T00:00:00.000Z", - mailMessageId: "", - }), - { status: 201 }, - ); - }) as unknown as typeof fetch; - - const result = await postQuestion(testConfig(fetchImpl), { - question: "Which environment?", - subtitle: "Pick the closest match.", - options: ["Staging", "Production"], - allowFreeText: true, - }); - - expect(seenUrl).toBe("https://hub.example.com/api/workflow-chat/participants/messages"); - expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token"); - expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); - const body = seenBody as { - parts: readonly { - kind: string; - block: { type: string; data: Record }; - }[]; - }; - expect(body.parts).toHaveLength(1); - expect(body.parts[0]?.kind).toBe("block"); - expect(body.parts[0]?.block.type).toBe("question"); - expect(body.parts[0]?.block.data["question"]).toBe("Which environment?"); - expect(body.parts[0]?.block.data["subtitle"]).toBe("Pick the closest match."); - expect(body.parts[0]?.block.data["options"]).toEqual(["Staging", "Production"]); - expect(body.parts[0]?.block.data["allowFreeText"]).toBe(true); - expect(typeof body.parts[0]?.block.data["questionId"]).toBe("string"); - expect(result.messageId).toBe("msg_1"); - expect(result.questionId).toBe(body.parts[0]?.block.data["questionId"] as string); -}); - -test("postQuestion stamps a caller-supplied questionId on the card instead of minting", async () => { - let seenBody: unknown; - const fetchImpl = (async (_url: string | URL, init?: RequestInit) => { - seenBody = JSON.parse(String(init?.body)); - return new Response( - JSON.stringify({ - id: "msg_1", - createdAt: "2026-08-17T00:00:00.000Z", - mailMessageId: "", - }), - { status: 201 }, - ); - }) as unknown as typeof fetch; - - const result = await postQuestion(testConfig(fetchImpl), { - question: "Which environment?", - options: ["Staging", "Production"], - questionId: "q_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }); - - const body = seenBody as { - parts: readonly { - kind: string; - block: { type: string; data: Record }; - }[]; - }; - expect(body.parts[0]?.block.data["questionId"]).toBe("q_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - expect(result.questionId).toBe("q_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - expect(result.messageId).toBe("msg_1"); -}); - -test("a 404 from the route surfaces as NoOwnChannelError", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { - code: "not_found", - userMessage: "no channel", - refId: "ref_test", - }, - }), - { status: 404 }, - )) as unknown as typeof fetch; - - try { - await postQuestion(testConfig(fetchImpl), { - question: "Q?", - options: ["a", "b"], - }); - throw new Error("expected NoOwnChannelError"); - } catch (error) { - expect(error).toBeInstanceOf(NoOwnChannelError); - expect((error as Error).message).toBe("no channel"); - } -}); - -test("a non-ok, non-404 response surfaces the envelope userMessage", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { - code: "internal_error", - userMessage: "the hub could not post the question", - refId: "ref_test", - }, - }), - { status: 500 }, - )) as unknown as typeof fetch; - - await expect( - postQuestion(testConfig(fetchImpl), { - question: "Q?", - options: ["a", "b"], - }), - ).rejects.toThrow("the hub could not post the question"); -}); - -test("reads userMessage even when a legacy message field is also present", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { - code: "internal_error", - message: "legacy", - userMessage: "canonical", - refId: "ref_test", - }, - }), - { status: 500 }, - )) as unknown as typeof fetch; - - try { - await postQuestion(testConfig(fetchImpl), { - question: "Q?", - options: ["a", "b"], - }); - throw new Error("expected postQuestion to throw"); - } catch (error) { - expect((error as Error).message).toBe("Posting the question failed: canonical"); - } -}); - -test("a 404 envelope with only error.message falls back, never the legacy field", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { code: "not_found", message: "legacy" }, - }), - { status: 404 }, - )) as unknown as typeof fetch; - - try { - await postQuestion(testConfig(fetchImpl), { - question: "Q?", - options: ["a", "b"], - }); - throw new Error("expected NoOwnChannelError"); - } catch (error) { - expect(error).toBeInstanceOf(NoOwnChannelError); - expect((error as Error).message).toBe("The caller has no channel of its own to post into"); - } -}); - -test("a non-ok, non-envelope response falls back to status text", async () => { - const fetchImpl = (async () => - new Response("boom", { - status: 500, - statusText: "Internal Server Error", - })) as unknown as typeof fetch; - - try { - await postQuestion(testConfig(fetchImpl), { - question: "Q?", - options: ["a", "b"], - }); - throw new Error("expected postQuestion to throw"); - } catch (error) { - expect((error as Error).message).toBe("Posting the question failed: 500 Internal Server Error"); - } -}); diff --git a/tools/interaction/src/client.ts b/tools/interaction/src/client.ts deleted file mode 100644 index 732b89c098..0000000000 --- a/tools/interaction/src/client.ts +++ /dev/null @@ -1,125 +0,0 @@ -// The one workflow-run-authenticated surface `ask_user` reaches: -// `@corbits/chat`'s `createWorkflowParticipantRoutes`' -// `POST .../participants/messages` — the generic "post a message into my -// own channel" route the same bundle family (`@corbits/agent-directory-tools`) -// already reaches for `participants/invite`. Same auth-header shape, same -// error-handling pattern as `@corbits/agent-directory-tools`'s `client.ts`. -import { createHash } from "node:crypto"; -import { type } from "arktype"; - -export interface AskUserClientConfig { - readonly hubChatUrl: string; - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; -} - -export interface AskUserQuestion { - readonly question: string; - readonly subtitle?: string; - readonly options: readonly string[]; - readonly allowFreeText?: boolean; - /** - * Stable id for this card. When omitted, `postQuestion` mints one. - * `ask_user` always supplies `questionIdForCall(call.id)` so a retry of - * the same tool call re-posts the same id — the participants/messages - * route treats that as a no-op and returns the existing card. - */ - readonly questionId?: string; -} - -function authHeaders(config: AskUserClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -/** Pulls `error.userMessage` out of the canonical hub envelope - * (`{error: {code, userMessage, refId}}`), if `body` matches that shape — - * same shape `@corbits/agent-directory-tools`' client reads. */ -function errorMessageFrom(body: unknown): string | undefined { - if (body === null || typeof body !== "object" || !("error" in body)) { - return undefined; - } - const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("userMessage" in error)) { - return undefined; - } - const userMessage = (error as { userMessage: unknown }).userMessage; - return typeof userMessage === "string" ? userMessage : undefined; -} - -async function readErrorMessage(response: Response, fallback: string): Promise { - const body: unknown = await response.json().catch(() => undefined); - return errorMessageFrom(body) ?? fallback; -} - -/** Thrown when the caller's run has no channel of its own to post into — - * the workflow-participant route's "not a participant of any channel" 404. */ -export class NoOwnChannelError extends Error {} - -const PostedMessageResponse = type({ id: "string", createdAt: "string" }); - -/** Derived from the runtime-assigned call id (never model-supplied) so a crash-retry re-posts the same card. */ -export function questionIdForCall(callId: string): string { - return `q_${createHash("sha256").update(callId).digest("hex").slice(0, 32)}`; -} - -/** - * Posts a `question` block; a caller-supplied `questionId` (never - * model-trusted) makes a block's `blockId` double as its `questionId`, the - * same way a poll's `blockId` is its `pollId`. Re-posting the same id is a - * no-op, so a crash between post and the tool call returning cannot orphan - * a duplicate on retry. - */ -export async function postQuestion( - config: AskUserClientConfig, - question: AskUserQuestion, -): Promise<{ readonly messageId: string; readonly questionId: string }> { - const doFetch = config.fetchImpl ?? fetch; - const questionId = question.questionId ?? `q_${crypto.randomUUID().replace(/-/g, "")}`; - const response = await doFetch(`${config.hubChatUrl}/api/workflow-chat/participants/messages`, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify({ - parts: [ - { - kind: "block", - block: { - type: "question", - data: { - questionId, - question: question.question, - ...(question.subtitle !== undefined ? { subtitle: question.subtitle } : {}), - options: question.options, - ...(question.allowFreeText !== undefined - ? { allowFreeText: question.allowFreeText } - : {}), - }, - }, - }, - ], - }), - }); - if (response.status === 404) { - throw new NoOwnChannelError( - await readErrorMessage(response, "The caller has no channel of its own to post into"), - ); - } - if (!response.ok) { - throw new Error( - `Posting the question failed: ${await readErrorMessage( - response, - `${response.status} ${response.statusText}`, - )}`, - ); - } - const body: unknown = await response.json(); - const parsed = PostedMessageResponse(body); - if (parsed instanceof type.errors) { - throw new Error(`Post-message response did not match the expected shape: ${parsed.summary}`); - } - return { messageId: parsed.id, questionId }; -} diff --git a/tools/interaction/src/index.ts b/tools/interaction/src/index.ts deleted file mode 100644 index 3c53f645e8..0000000000 --- a/tools/interaction/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { postQuestion, NoOwnChannelError } from "./client"; -export type { AskUserClientConfig, AskUserQuestion } from "./client"; -export { ASK_USER_TOOL, interactionTools } from "./tool"; -export type { AskUserEnv } from "./tool"; diff --git a/tools/interaction/src/tool.test.ts b/tools/interaction/src/tool.test.ts deleted file mode 100644 index a1752f5cc5..0000000000 --- a/tools/interaction/src/tool.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { expect, test } from "bun:test"; - -import { ASK_USER_TOOL, interactionTools } from "./tool"; -import type { AskUserEnv } from "./tool"; - -function testEnv(): AskUserEnv { - return { - hubChatUrl: "https://hub.example.com", - sidecarToken: "sc-token", - address: "run_1@workflow", - } as unknown as AskUserEnv; -} - -async function withFetch(fetchImpl: typeof fetch, run: () => Promise): Promise { - const original = globalThis.fetch; - globalThis.fetch = fetchImpl; - try { - return await run(); - } finally { - globalThis.fetch = original; - } -} - -test("declares exactly ask_user, with no approval gate", () => { - expect(interactionTools.definitions).toEqual([{ name: ASK_USER_TOOL }]); -}); - -test("requires the sanctioned env keys", () => { - expect(interactionTools.requires).toEqual(["hubChatUrl", "sidecarToken", "address"]); -}); - -test("ask_user posts a question block and ends the turn with the answer arriving as the next message", async () => { - let posted = false; - const fetchImpl = (async () => { - posted = true; - return new Response( - JSON.stringify({ - id: "msg_1", - createdAt: "2026-08-17T00:00:00.000Z", - mailMessageId: "", - }), - { status: 201 }, - ); - }) as unknown as typeof fetch; - - const bundle = interactionTools(testEnv()); - const call = { - id: "call_1", - name: ASK_USER_TOOL, - arguments: { - question: "Which environment?", - options: ["Staging", "Production"], - }, - }; - const result = await withFetch(fetchImpl, () => bundle.run(call, new AbortController().signal)); - - expect(posted).toBe(true); - expect(result.isError).toBeFalsy(); - expect(result.callId).toBe("call_1"); - expect(String(result.content)).toMatch(/end this turn/i); - expect(String(result.content)).toMatch(/next inbound message/i); -}); - -test("ask_user's result text tells the model to stop, not to wait for a reply here", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ id: "msg_1", createdAt: "2026-08-17T00:00:00.000Z" }), { - status: 201, - })) as unknown as typeof fetch; - - const bundle = interactionTools(testEnv()); - const result = await withFetch(fetchImpl, () => - bundle.run( - { - id: "call_1", - name: ASK_USER_TOOL, - arguments: { question: "Which environment?", options: ["A", "B"] }, - }, - new AbortController().signal, - ), - ); - - // This is instruction text in the model's own context, not something the - // runtime enforces: nothing stops the model from calling another tool or - // continuing to talk after reading it (docs/CHAT.md). - expect(String(result.content)).toMatch(/end this turn now/i); - expect(String(result.content)).toMatch(/do not wait for a reply here/i); -}); - -test("retrying ask_user for the same call reuses the questionId so a crash between post and return cannot orphan a second card", async () => { - const postedQuestionIds: string[] = []; - const fetchImpl = (async (_url: string | URL, init?: RequestInit) => { - const body = JSON.parse(String(init?.body)) as { - parts: readonly { - block: { data: Record }; - }[]; - }; - const questionId = body.parts[0]?.block.data["questionId"]; - if (typeof questionId === "string") postedQuestionIds.push(questionId); - return new Response( - JSON.stringify({ - id: "msg_1", - createdAt: "2026-08-17T00:00:00.000Z", - mailMessageId: "", - }), - { status: 201 }, - ); - }) as unknown as typeof fetch; - - const bundle = interactionTools(testEnv()); - const call = { - id: "call_1", - name: ASK_USER_TOOL, - arguments: { - question: "Which environment?", - options: ["Staging", "Production"], - }, - }; - - await withFetch(fetchImpl, () => bundle.run(call, new AbortController().signal)); - await withFetch(fetchImpl, () => bundle.run(call, new AbortController().signal)); - - expect(postedQuestionIds).toHaveLength(2); - const reusedId = postedQuestionIds[0]; - if (reusedId === undefined) { - throw new Error("expected a posted questionId"); - } - expect(postedQuestionIds[1]).toBe(reusedId); - expect(reusedId).toMatch(/^q_[0-9a-f]{32}$/); -}); - -test("ask_user rejects fewer than 2 options before ever posting", async () => { - let posted = false; - const fetchImpl = (async () => { - posted = true; - return new Response("{}", { status: 201 }); - }) as unknown as typeof fetch; - - const bundle = interactionTools(testEnv()); - const result = await withFetch(fetchImpl, () => - bundle.run( - { - id: "call_1", - name: ASK_USER_TOOL, - arguments: { question: "Q?", options: ["only one"] }, - }, - new AbortController().signal, - ), - ); - - expect(posted).toBe(false); - expect(result.isError).toBe(true); -}); - -test("ask_user surfaces a no-own-channel failure as an error result, not a throw", async () => { - const fetchImpl = (async () => - new Response( - JSON.stringify({ - error: { - code: "not_found", - userMessage: "no channel found", - refId: "ref_test", - }, - }), - { status: 404 }, - )) as unknown as typeof fetch; - - const bundle = interactionTools(testEnv()); - const result = await withFetch(fetchImpl, () => - bundle.run( - { - id: "call_1", - name: ASK_USER_TOOL, - arguments: { question: "Q?", options: ["a", "b"] }, - }, - new AbortController().signal, - ), - ); - - expect(result.isError).toBe(true); - expect(result.content).toBe("no channel found"); -}); - -test("an unknown tool name returns an honest error", async () => { - const bundle = interactionTools(testEnv()); - const result = await bundle.run( - { id: "call_1", name: "delete_everything", arguments: {} }, - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/unknown tool/); -}); diff --git a/tools/interaction/src/tool.ts b/tools/interaction/src/tool.ts deleted file mode 100644 index 0ab70472ca..0000000000 --- a/tools/interaction/src/tool.ts +++ /dev/null @@ -1,140 +0,0 @@ -// A Workbench agent is an unbounded interactive step — every inbound mail -// is its next turn — so "ask a person" is native as "post the question and -// stop," not a structural park: the answer arrives as the next turn's -// inbound message, not as this call's result. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { type } from "arktype"; - -import { reportError } from "@corbits/error-sink"; - -import { postQuestion, questionIdForCall } from "./client"; -import type { AskUserClientConfig } from "./client"; - -export const ASK_USER_TOOL = "ask_user"; - -export interface AskUserEnv extends BaseEnv { - readonly hubChatUrl: string; - readonly sidecarToken: string; - readonly address: string; -} - -const AskUserInput = type({ - question: "string > 0", - "subtitle?": "string", - options: "2 <= string[] <= 6", - "allowFreeText?": "boolean", -}); -type AskUserInput = typeof AskUserInput.infer; - -function errorResult(callId: string, err: unknown): ToolResult { - return { - callId, - isError: true, - content: err instanceof Error ? err.message : String(err), - }; -} - -function clientConfig(env: AskUserEnv): AskUserClientConfig { - return { - hubChatUrl: env.hubChatUrl, - sidecarToken: env.sidecarToken, - address: env.address, - }; -} - -/** Posts the question then tells the model to end its turn; postQuestion keys the card on the call id so a crash-retry re-posts the same id, not a duplicate. */ -async function runAskUser(env: AskUserEnv, call: ToolCall): Promise { - const parsed = AskUserInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`${ASK_USER_TOOL} received invalid input: ${parsed.summary}`), - ); - } - - try { - await postQuestion(clientConfig(env), { - ...parsed, - questionId: questionIdForCall(call.id), - }); - } catch (err) { - reportError(err, { - operation: "ask_user_post_question", - agentId: env.address, - }); - return errorResult(call.id, err); - } - - return { - callId: call.id, - isError: false, - content: - "Question posted to the user. Do not wait for a reply here or call " + - "another tool for it: end this turn now. The user's answer will " + - "arrive as your next inbound message, not as this call's result.", - }; -} - -/** - * The `@corbits/interaction-tools` bundle factory: one tool, `ask_user`, - * for posing an enumerable-option interview question as an in-thread card - * instead of a prose list. No approval gate — showing a question is not an - * external side effect — and no `message_response` gate either: the call - * posts and returns immediately, ending the turn. - */ -export const interactionTools = defineTool({ - id: "@corbits/interaction-tools/ask-user", - requires: ["hubChatUrl", "sidecarToken", "address"], - definitions: [{ name: ASK_USER_TOOL }], - factory: (env) => ({ - definitions: [ - { - name: ASK_USER_TOOL, - description: - "Asks the user a single interview question with lettered " + - "options, rendered as an interactive card in the conversation " + - "instead of a prose list. Use this whenever interviewing the " + - "user with a small set of enumerable options (2-6), rather " + - "than writing the options out as text. Ends your turn once " + - "posted: the user's answer arrives as your next message, not " + - "as this call's result.", - inputSchema: { - type: "object", - properties: { - question: { - type: "string", - description: "The question's title, e.g. the interview prompt.", - }, - subtitle: { - type: "string", - description: "Optional supporting context shown under the title.", - }, - options: { - type: "array", - items: { type: "string" }, - minItems: 2, - maxItems: 6, - description: "2-6 answer options, rendered as lettered choices.", - }, - allowFreeText: { - type: "boolean", - description: - 'Whether to also show a "Type your own answer" field. ' + "Defaults to false.", - }, - }, - required: ["question", "options"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - if (call.name === ASK_USER_TOOL) { - return runAskUser(env, call); - } - return Promise.resolve( - errorResult(call.id, new Error(`@corbits/interaction-tools: unknown tool "${call.name}"`)), - ); - }, - }), -}); diff --git a/tools/interaction/tsconfig.json b/tools/interaction/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/interaction/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/interaction/tsconfig.src.json b/tools/interaction/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/interaction/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/skills/LICENSE b/tools/skills/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/skills/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/skills/README.md b/tools/skills/README.md deleted file mode 100644 index 8f94d7490e..0000000000 --- a/tools/skills/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# @corbits/skills-tools - -SKILL.md authoring and the pinned-skills prompt index, plus every -skills-related `@intx/agent` tool in one bundle: - -- `skillsManageTools` — `list_skills`, `read_skill`, `create_skill`, - `update_skill`, `pin_skill`: Myra's in-chat way to capture know-how as a - skill and pin one onto an agent definition. -- `skillsQueryTools` — `skills_list`, `skills_search`, `skills_load`: the - agent-facing half, turning an `` index entry into the - skill's full body. - -`skill-md.ts` and `prompt.ts` are internal modules re-exported from the -package root because `@corbits/agent-directory` still consumes -`skillNameSchema`, `PinnedSkillIndexEntry`, and `withAvailableSkills` -directly. - -## Running tests - -```sh -cd tools/skills && bun test -``` diff --git a/tools/skills/package.json b/tools/skills/package.json deleted file mode 100644 index af37fd0d32..0000000000 --- a/tools/skills/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@corbits/skills-tools", - "version": "0.0.9", - "description": "SKILL.md authoring, the pinned-skills prompt index, and the list_skills/read_skill/create_skill/update_skill/pin_skill + skills_list/skills_search/skills_load @intx/agent tool bundles, over native kind:\"skill\" hub assets and @corbits/agent-directory's definition_skills store", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/skills" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - } -} diff --git a/tools/skills/src/index.ts b/tools/skills/src/index.ts deleted file mode 100644 index 005050abd2..0000000000 --- a/tools/skills/src/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -export { - AVAILABLE_SKILLS_CLOSE_TAG, - AVAILABLE_SKILLS_OPEN_TAG, - SKILLS_LOAD_TOOL, - buildAvailableSkillsStanza, - stripAvailableSkillsStanza, - withAvailableSkills, - type PinnedSkillIndexEntry, -} from "./prompt"; -export { - SKILL_MD_FILENAME, - SkillContentError, - buildSkillMd, - decodeSkillMd, - parseSkillMd, - skillDescriptionSchema, - skillFrontmatterSchema, - skillNameSchema, - skillScopeSchema, - type ParsedSkillMd, - type SkillFrontmatter, - type SkillScope, -} from "./skill-md"; -export { - skillsManageTools, - CREATE_SKILL_TOOL, - LIST_SKILLS_TOOL, - PIN_SKILL_TOOL, - READ_SKILL_TOOL, - UPDATE_SKILL_TOOL, - type WorkflowSkillsWriteEnv, -} from "./manage-tools"; -export { - skillsQueryTools, - SKILLS_LIST_TOOL, - SKILLS_SEARCH_TOOL, - type WorkflowSkillsToolEnv, -} from "./query-tools"; diff --git a/tools/skills/src/manage-client.test.ts b/tools/skills/src/manage-client.test.ts deleted file mode 100644 index 1a1ecb7cf5..0000000000 --- a/tools/skills/src/manage-client.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - createSkill, - listSkills, - loadSkill, - pinSkill, - updateSkill, - type SkillsToolClientConfig, -} from "./manage-client"; - -function testConfig(fetchImpl: typeof fetch): SkillsToolClientConfig { - return { - hubSkillsUrl: "https://hub.example.com", - hubAgentDirectoryUrl: "https://hub.example.com", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - }; -} - -// the workflow-skills HTTP surface these four calls used to -// reach was deleted, and no stock Interchange route yet serves skill -// content, so each fails closed with an explicit error instead of -// reaching a dead route. -const unreachableFetch = (() => { - throw new Error("must not be called: no stock route exists to call"); -}) as unknown as typeof fetch; - -test("listSkills fails closed naming the missing stock route", async () => { - await expect(listSkills(testConfig(unreachableFetch))).rejects.toThrow( - /no stock Interchange HTTP route/, - ); -}); - -test("loadSkill fails closed naming the missing stock route", async () => { - await expect(loadSkill(testConfig(unreachableFetch), "triage")).rejects.toThrow( - /no stock Interchange HTTP route/, - ); -}); - -test("createSkill fails closed naming the missing stock route", async () => { - await expect( - createSkill(testConfig(unreachableFetch), { - name: "triage", - description: "d", - body: "b", - }), - ).rejects.toThrow(/no stock Interchange HTTP route/); -}); - -test("updateSkill fails closed naming the missing stock route", async () => { - await expect( - updateSkill(testConfig(unreachableFetch), { name: "triage", body: "b" }), - ).rejects.toThrow(/no stock Interchange HTTP route/); -}); - -test("pinSkill posts to the agent-directory workflow-skill-pins surface", async () => { - let seenUrl: string | undefined; - let seenBody: unknown; - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seenUrl = String(url); - seenBody = JSON.parse(String(init?.body)); - return new Response(JSON.stringify({ skills: ["triage", "research"] })); - }) as unknown as typeof fetch; - - const skills = await pinSkill(testConfig(fetchImpl), { - definitionId: "def_1", - skillName: "research", - }); - - expect(seenUrl).toBe("https://hub.example.com/api/workflow-skill-pins/pin"); - expect(seenBody).toEqual({ definitionId: "def_1", skillName: "research" }); - expect(skills).toEqual(["triage", "research"]); -}); - -test("pinSkill throws on a response that doesn't match the expected shape", async () => { - const fetchImpl = (async () => - new Response(JSON.stringify({ nonsense: true }))) as unknown as typeof fetch; - - await expect( - pinSkill(testConfig(fetchImpl), { - definitionId: "def_1", - skillName: "research", - }), - ).rejects.toThrow(/expected shape/); -}); diff --git a/tools/skills/src/manage-client.ts b/tools/skills/src/manage-client.ts deleted file mode 100644 index ce488db35b..0000000000 --- a/tools/skills/src/manage-client.ts +++ /dev/null @@ -1,153 +0,0 @@ -// A minimal client for the workflow-run-authenticated surfaces -// `@corbits/skills-tools`' tools call. -// -// `@corbits/skills`' `createWorkflowSkillRoutes` (formerly -// mounted at `/api/workflow-skills`, serving `list`/`search`/`load` plus -// the `create`/`update` writes) was deleted — skills are native -// `kind:"skill"` hub assets now, and no stock Interchange route yet lets -// a workflow-run bearer identity read or write a skill's content -// (`@intx/hub-api`'s `routes/assets.ts` covers asset metadata and -// package-registry tarballs, not skill content). `listSkills`, -// `loadSkill`, `createSkill`, and `updateSkill` below fail closed with an -// explicit error naming that gap rather than reaching a dead route or -// inventing a result. -// -// `pinSkill` is unaffected: it calls `@corbits/agent-directory`'s -// `createWorkflowSkillPinRoutes` (mounted at `/api/workflow-skill-pins`), -// a live, separate surface this ticket does not touch. -import { type } from "arktype"; - -export interface SkillsToolClientConfig { - /** The hub's plain HTTP origin serving `@corbits/skills`' workflow - * routes — same value `@corbits/memory-tools`' `hubMemoryUrl` and - * `@corbits/capability-tools`' `hubCapabilitiesUrl` reach the hub - * through. */ - readonly hubSkillsUrl: string; - /** The hub's plain HTTP origin serving `@corbits/agent-directory`'s - * workflow routes. Declared separately from `hubSkillsUrl` even - * though both currently resolve to the same hub origin in every - * deployment this repo ships — the two write surfaces belong to - * different packages, and a host is free to split them apart. */ - readonly hubAgentDirectoryUrl: string; - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; -} - -export type SkillSummary = { - readonly assetId: string; - readonly name: string; - readonly description: string; - readonly scope: "private" | "tenant"; - readonly creatorPrincipalId: string; - readonly updatedAtIso: string; -}; - -export type SkillIndexEntry = { - readonly name: string; - readonly description: string; -}; - -const PinResponse = type({ skills: "string[]" }); - -export type SkillDetail = { - readonly name: string; - readonly description: string; - readonly body: string; -}; - -function authHeaders(config: SkillsToolClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -async function postJson( - config: SkillsToolClientConfig, - url: string, - body: unknown, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch(url, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const errorBody: unknown = await response.json().catch(() => undefined); - throw new Error( - `${url} failed: ${response.status} ${response.statusText}${ - errorBody === undefined ? "" : ` — ${JSON.stringify(errorBody)}` - }`, - ); - } - return response.json(); -} - -function skillPinEndpoint(config: SkillsToolClientConfig, path: string): string { - return `${config.hubAgentDirectoryUrl}/api/workflow-skill-pins${path}`; -} - -const NO_STOCK_SKILL_CONTENT_ROUTE = - "Skill content has no stock Interchange HTTP route: the " + - "workbench-specific skills registry that used to serve it was removed, " + - "and no replacement has been added to @intx/hub-api yet."; - -/** Every skill this run can see, index-only (name + description, no - * body). No stock Interchange route serves this yet; fails - * closed rather than reaching a dead route or reading as an empty - * registry. */ -export function listSkills(_config: SkillsToolClientConfig): Promise { - return Promise.reject(new Error(NO_STOCK_SKILL_CONTENT_ROUTE)); -} - -/** Loads one skill's full body by name. No stock Interchange route - * serves this yet; fails closed rather than fabricating - * content. */ -export function loadSkill(_config: SkillsToolClientConfig, _name: string): Promise { - return Promise.reject(new Error(NO_STOCK_SKILL_CONTENT_ROUTE)); -} - -/** Creates a new, always tenant-scoped skill. No stock Interchange route - * accepts skill content yet; fails closed rather than - * fabricating success. */ -export function createSkill( - _config: SkillsToolClientConfig, - _input: { - readonly name: string; - readonly description: string; - readonly body: string; - }, -): Promise { - return Promise.reject(new Error(NO_STOCK_SKILL_CONTENT_ROUTE)); -} - -/** Republishes an existing skill's body. No stock Interchange route - * accepts skill content yet; fails closed rather than - * fabricating success. */ -export function updateSkill( - _config: SkillsToolClientConfig, - _input: { - readonly name: string; - readonly body: string; - readonly description?: string; - }, -): Promise { - return Promise.reject(new Error(NO_STOCK_SKILL_CONTENT_ROUTE)); -} - -/** Pins a skill name onto any definition in this run's own tenant. - * Returns that definition's full pinned-skill list after the pin. */ -export async function pinSkill( - config: SkillsToolClientConfig, - input: { readonly definitionId: string; readonly skillName: string }, -): Promise { - const body = await postJson(config, skillPinEndpoint(config, "/pin"), input); - const parsed = PinResponse(body); - if (parsed instanceof type.errors) { - throw new Error(`Pin-skill response did not match the expected shape: ${parsed.summary}`); - } - return parsed.skills; -} diff --git a/tools/skills/src/manage-tools.test.ts b/tools/skills/src/manage-tools.test.ts deleted file mode 100644 index 6f974cfbdd..0000000000 --- a/tools/skills/src/manage-tools.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { - skillsManageTools, - CREATE_SKILL_TOOL, - LIST_SKILLS_TOOL, - PIN_SKILL_TOOL, - READ_SKILL_TOOL, - UPDATE_SKILL_TOOL, - type WorkflowSkillsWriteEnv, -} from "./manage-tools"; - -function testEnv(): WorkflowSkillsWriteEnv { - return { - hubSkillsUrl: "https://hub.example.com", - hubAgentDirectoryUrl: "https://hub.example.com", - sidecarToken: "sc-token", - address: "run_1@workflow", - } as unknown as WorkflowSkillsWriteEnv; -} - -function callFor(name: string, args: Record): ToolCall { - return { id: "call_1", name, arguments: args }; -} - -async function withFetch(impl: typeof fetch, run: () => Promise): Promise { - const original = globalThis.fetch; - globalThis.fetch = impl; - try { - return await run(); - } finally { - globalThis.fetch = original; - } -} - -test("declares exactly the five skills tools", () => { - const bundle = skillsManageTools(testEnv()); - expect(bundle.definitions.map((d) => d.name)).toEqual([ - LIST_SKILLS_TOOL, - READ_SKILL_TOOL, - CREATE_SKILL_TOOL, - UPDATE_SKILL_TOOL, - PIN_SKILL_TOOL, - ]); -}); - -test("requires the sanctioned workflow-skills-write env keys", () => { - expect(skillsManageTools.requires).toEqual([ - "hubSkillsUrl", - "hubAgentDirectoryUrl", - "sidecarToken", - "address", - ]); -}); - -test('the two reads and the two grant-free writes (create_skill, update_skill) carry no approval gate; pin_skill, which changes another agent\'s own behavior, keeps approval: "ask"', () => { - expect(skillsManageTools.definitions).toEqual([ - { name: LIST_SKILLS_TOOL }, - { name: READ_SKILL_TOOL }, - { name: CREATE_SKILL_TOOL }, - { name: UPDATE_SKILL_TOOL }, - { name: PIN_SKILL_TOOL, approval: "ask" }, - ]); -}); - -test("read_skill rejects a call missing the name without calling out", async () => { - const bundle = skillsManageTools(testEnv()); - const result = await bundle.run(callFor(READ_SKILL_TOOL, {}), new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/invalid input/); -}); - -// the workflow-skills HTTP surface these tools used to call was -// deleted, and no stock Interchange route yet serves skill content, so -// each fails closed with an explicit error instead of a fabricated -// result. -test("read_skill surfaces the missing stock route as an honest tool error", async () => { - const bundle = skillsManageTools(testEnv()); - const result = await bundle.run( - callFor(READ_SKILL_TOOL, { name: "triage" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/no stock Interchange HTTP route/); -}); - -test("list_skills surfaces the missing stock route as an honest tool error, never an empty list", async () => { - const bundle = skillsManageTools(testEnv()); - const result = await bundle.run(callFor(LIST_SKILLS_TOOL, {}), new AbortController().signal); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/no stock Interchange HTTP route/); -}); - -test("create_skill rejects a call missing a required field without calling out", async () => { - const bundle = skillsManageTools(testEnv()); - const result = await bundle.run( - callFor(CREATE_SKILL_TOOL, { name: "triage", body: "b" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/invalid input/); -}); - -test("create_skill surfaces the missing stock route as an honest tool error, never a fabricated success", async () => { - const bundle = skillsManageTools(testEnv()); - const result = await bundle.run( - callFor(CREATE_SKILL_TOOL, { - name: "triage", - description: "Sorts inbound issues.", - body: "Read the report.", - }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/no stock Interchange HTTP route/); -}); - -test("update_skill surfaces the missing stock route as an honest tool error, never a fabricated success", async () => { - const bundle = skillsManageTools(testEnv()); - const result = await bundle.run( - callFor(UPDATE_SKILL_TOOL, { name: "triage", body: "New body." }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/no stock Interchange HTTP route/); -}); - -test("pin_skill reports the definition's full pinned-skill list after the pin", async () => { - const bundle = skillsManageTools(testEnv()); - let seenUrl: string | undefined; - let seenBody: unknown; - const result = await withFetch( - (async (url: string | URL, init?: RequestInit) => { - seenUrl = String(url); - seenBody = JSON.parse(String(init?.body)); - return new Response(JSON.stringify({ skills: ["triage", "research"] })); - }) as unknown as typeof fetch, - () => - bundle.run( - callFor(PIN_SKILL_TOOL, { - definitionId: "def_1", - skillName: "research", - }), - new AbortController().signal, - ), - ); - expect(seenUrl).toBe("https://hub.example.com/api/workflow-skill-pins/pin"); - expect(seenBody).toEqual({ definitionId: "def_1", skillName: "research" }); - expect(result.isError).toBeFalsy(); - expect(result.content).toBe('Pinned "research" — this definition now carries: triage, research.'); -}); - -test("pin_skill rejects a call missing skillName without calling out", async () => { - const bundle = skillsManageTools(testEnv()); - const result = await bundle.run( - callFor(PIN_SKILL_TOOL, { definitionId: "def_1" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(result.content).toMatch(/invalid input/); -}); diff --git a/tools/skills/src/manage-tools.ts b/tools/skills/src/manage-tools.ts deleted file mode 100644 index 4781f65d59..0000000000 --- a/tools/skills/src/manage-tools.ts +++ /dev/null @@ -1,319 +0,0 @@ -// The `@corbits/skills-tools` bundle: Myra's in-chat way to capture -// repeatable know-how as a workbench skill (`create_skill`/ -// `update_skill`), browse what already exists (`list_skills`), and pin -// an existing skill onto any agent definition in the workbench -// (`pin_skill`) — the CL-Myra-manager-tools counterpart to -// `@corbits/capability-tools`' `request_capability`, over the same -// workflow-run-authenticated surfaces `./client.ts` calls. -// -// `create_skill` and `update_skill` grant no credentials or -// capability pins and write only a tenant-internal skill row — a -// skill's body is durable know-how, not something any agent's -// capabilities or autonomy hinge on until a human separately pins it -// onto a definition. `pin_skill`, by contrast, still declares -// `approval: "ask"` (`@intx/agent`'s native per-invocation gate, -// `@intx/agent/src/tool.ts`): it can change ANY agent -// definition's own behavior, not just this run's — the reactor -// suspends the call as a pending approval BEFORE this bundle's `run` -// ever executes, and only resumes once a human allows it. `list_skills` -// and `read_skill` are plain reading and carry no approval gate. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { type } from "arktype"; - -import { - createSkill, - listSkills, - loadSkill, - pinSkill, - updateSkill, - type SkillsToolClientConfig, -} from "./manage-client"; - -export const LIST_SKILLS_TOOL = "list_skills"; -export const READ_SKILL_TOOL = "read_skill"; -export const CREATE_SKILL_TOOL = "create_skill"; -export const UPDATE_SKILL_TOOL = "update_skill"; -export const PIN_SKILL_TOOL = "pin_skill"; - -/** Env this bundle needs beyond `BaseEnv`: the run's reach into both - * hub surfaces it calls, mirroring `@corbits/capability-tools`' - * `WorkflowCapabilityEnv`. */ -export interface WorkflowSkillsWriteEnv extends BaseEnv { - readonly hubSkillsUrl: string; - readonly hubAgentDirectoryUrl: string; - readonly sidecarToken: string; - readonly address: string; -} - -function clientConfig(env: WorkflowSkillsWriteEnv): SkillsToolClientConfig { - return { - hubSkillsUrl: env.hubSkillsUrl, - hubAgentDirectoryUrl: env.hubAgentDirectoryUrl, - sidecarToken: env.sidecarToken, - address: env.address, - }; -} - -function errorResult(callId: string, err: unknown): ToolResult { - return { - callId, - isError: true, - content: err instanceof Error ? err.message : String(err), - }; -} - -const CreateSkillInput = type({ - name: "string > 0", - description: "string > 0", - body: "string > 0", -}); - -// `name`, not `assetId` — deliberate: `SkillRegistry` (`@corbits/skills`) -// is name-keyed throughout (`load`, `versions`, `restore`, `setScope` -// all take a skill name, never an asset id), so a tool built over it -// stays in that same vocabulary rather than surfacing an id the model -// has no other way to have learned. -const UpdateSkillInput = type({ - name: "string > 0", - body: "string > 0", - "description?": "string > 0", -}); - -const PinSkillInput = type({ - definitionId: "string > 0", - skillName: "string > 0", -}); - -const ReadSkillInput = type({ - name: "string > 0", -}); - -async function runReadSkill(env: WorkflowSkillsWriteEnv, call: ToolCall): Promise { - const parsed = ReadSkillInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult(call.id, new Error(`read_skill received invalid input: ${parsed.summary}`)); - } - try { - const skill = await loadSkill(clientConfig(env), parsed.name); - return { - callId: call.id, - isError: false, - content: `# ${skill.name}\n${skill.description}\n\n${skill.body}`, - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runListSkills(env: WorkflowSkillsWriteEnv, call: ToolCall): Promise { - try { - const skills = await listSkills(clientConfig(env)); - return { - callId: call.id, - isError: false, - content: - skills.length === 0 - ? "No skills exist in this workbench yet." - : skills.map((skill) => `${skill.name}: ${skill.description}`).join("\n"), - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runCreateSkill(env: WorkflowSkillsWriteEnv, call: ToolCall): Promise { - const parsed = CreateSkillInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`create_skill received invalid input: ${parsed.summary}`), - ); - } - try { - const skill = await createSkill(clientConfig(env), parsed); - return { - callId: call.id, - isError: false, - content: `Created the "${skill.name}" skill.`, - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runUpdateSkill(env: WorkflowSkillsWriteEnv, call: ToolCall): Promise { - const parsed = UpdateSkillInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult( - call.id, - new Error(`update_skill received invalid input: ${parsed.summary}`), - ); - } - try { - const skill = await updateSkill(clientConfig(env), parsed); - return { - callId: call.id, - isError: false, - content: `Updated the "${skill.name}" skill.`, - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runPinSkill(env: WorkflowSkillsWriteEnv, call: ToolCall): Promise { - const parsed = PinSkillInput(call.arguments); - if (parsed instanceof type.errors) { - return errorResult(call.id, new Error(`pin_skill received invalid input: ${parsed.summary}`)); - } - try { - const skills = await pinSkill(clientConfig(env), parsed); - return { - callId: call.id, - isError: false, - content: `Pinned "${parsed.skillName}" — this definition now carries: ${skills.join(", ")}.`, - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -/** - * The `@corbits/skills-tools` bundle factory: four tools over the - * workbench skill registry and definition-skill pins. - */ -export const skillsManageTools = defineTool({ - id: "@corbits/skills-tools/skills", - requires: ["hubSkillsUrl", "hubAgentDirectoryUrl", "sidecarToken", "address"], - definitions: [ - { name: LIST_SKILLS_TOOL }, - { name: READ_SKILL_TOOL }, - { name: CREATE_SKILL_TOOL }, - { name: UPDATE_SKILL_TOOL }, - { name: PIN_SKILL_TOOL, approval: "ask" }, - ], - factory: (env) => ({ - definitions: [ - { - name: LIST_SKILLS_TOOL, - description: - "List every skill visible in this workbench, as a name and " + - "one-line description for each. Use this to see what already " + - "exists before offering to create a new one.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: READ_SKILL_TOOL, - description: - "Read one skill's full instructions by name. Use this when " + - "you are about to do the thing a skill covers — load it and " + - "follow it rather than working from the one-line description.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "The exact name of the skill to read.", - }, - }, - required: ["name"], - }, - }, - { - name: CREATE_SKILL_TOOL, - description: - "Capture repeatable know-how as a new, workbench-wide skill. " + - "Use this only once a genuinely reusable procedure has come " + - "up in conversation — never speculatively.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: 'A lowercase, hyphenated skill name (e.g. "triage-bugs").', - }, - description: { - type: "string", - description: "One sentence on what the skill does and when to use it.", - }, - body: { - type: "string", - description: "The skill's full instructions, in Markdown.", - }, - }, - required: ["name", "description", "body"], - }, - }, - { - name: UPDATE_SKILL_TOOL, - description: - "Republish an existing skill's instructions — a new version " + - "on the same skill, not a new skill. Omit description to " + - "leave it exactly as it already is.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "The exact name of the existing skill to update.", - }, - body: { - type: "string", - description: "The skill's full, replacement instructions, in Markdown.", - }, - description: { - type: "string", - description: - "Optional replacement one-line description. Left unset, " + - "the skill's current description is kept as-is.", - }, - }, - required: ["name", "body"], - }, - }, - { - name: PIN_SKILL_TOOL, - description: - "Pin an existing skill onto an agent definition in this " + - "workbench, so every run of that agent gets the skill listed " + - "as available to it. Works on any definition in the " + - "workbench, not only this agent's own. A human must approve " + - "before the pin is written.", - inputSchema: { - type: "object", - properties: { - definitionId: { - type: "string", - description: "The id of the agent definition to pin the skill onto.", - }, - skillName: { - type: "string", - description: "The exact name of an existing skill.", - }, - }, - required: ["definitionId", "skillName"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - switch (call.name) { - case LIST_SKILLS_TOOL: - return runListSkills(env, call); - case READ_SKILL_TOOL: - return runReadSkill(env, call); - case CREATE_SKILL_TOOL: - return runCreateSkill(env, call); - case UPDATE_SKILL_TOOL: - return runUpdateSkill(env, call); - case PIN_SKILL_TOOL: - return runPinSkill(env, call); - default: - return Promise.resolve( - errorResult(call.id, new Error(`@corbits/skills-tools: unknown tool "${call.name}"`)), - ); - } - }, - }), -}); diff --git a/tools/skills/src/prompt.test.ts b/tools/skills/src/prompt.test.ts deleted file mode 100644 index 898402c769..0000000000 --- a/tools/skills/src/prompt.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AVAILABLE_SKILLS_CLOSE_TAG, - AVAILABLE_SKILLS_OPEN_TAG, - buildAvailableSkillsStanza, - stripAvailableSkillsStanza, - withAvailableSkills, -} from "./prompt"; - -const PINNED = [ - { name: "triage", description: "Sorts inbound issues." }, - { name: "summarize", description: "Condenses a long thread." }, -]; - -describe("buildAvailableSkillsStanza", () => { - test("lists each pinned skill's name and description", () => { - const stanza = buildAvailableSkillsStanza(PINNED); - expect(stanza).toContain("- triage: Sorts inbound issues."); - expect(stanza).toContain("- summarize: Condenses a long thread."); - }); - - test("names skills_load as the way to read a body", () => { - expect(buildAvailableSkillsStanza(PINNED)).toContain("skills_load"); - }); - - test("is empty when nothing is pinned", () => { - expect(buildAvailableSkillsStanza([])).toBe(""); - }); -}); - -describe("withAvailableSkills", () => { - test("appends the stanza after the author's prompt", () => { - const prompt = withAvailableSkills("You are a helpful agent.", PINNED); - expect(prompt.startsWith("You are a helpful agent.")).toBe(true); - expect(prompt).toContain(AVAILABLE_SKILLS_OPEN_TAG); - expect(prompt).toContain(AVAILABLE_SKILLS_CLOSE_TAG); - }); - - test("carries no skill body — the index is names and descriptions only", () => { - const prompt = withAvailableSkills("Base.", [ - { name: "triage", description: "Sorts inbound issues." }, - ]); - expect(prompt).not.toContain("Step 1: read the issue"); - }); - - test("re-pinning replaces the prior stanza instead of stacking one", () => { - const once = withAvailableSkills("Base.", PINNED); - const twice = withAvailableSkills(once, PINNED); - expect(twice).toBe(once); - expect(twice.split(AVAILABLE_SKILLS_OPEN_TAG)).toHaveLength(2); - }); - - test("unpinning every skill leaves the author's prompt alone", () => { - const pinned = withAvailableSkills("You are a helpful agent.", PINNED); - expect(withAvailableSkills(pinned, [])).toBe("You are a helpful agent."); - }); -}); - -describe("stripAvailableSkillsStanza", () => { - test("removes an injected stanza wherever it sits", () => { - const prompt = withAvailableSkills("Base prompt.", PINNED); - expect(stripAvailableSkillsStanza(prompt)).toBe("Base prompt."); - }); - - test("leaves a prompt that never carried one untouched", () => { - expect(stripAvailableSkillsStanza("Base prompt.")).toBe("Base prompt."); - }); -}); diff --git a/tools/skills/src/prompt.ts b/tools/skills/src/prompt.ts deleted file mode 100644 index d338350cf3..0000000000 --- a/tools/skills/src/prompt.ts +++ /dev/null @@ -1,63 +0,0 @@ -// The pinned-skills prompt index. A definition that pins skills gets an -// `` stanza appended to its system prompt at push -// time — name and description only, plus the instruction to call -// `skills_load` for the body. -// -// Index-only is the whole point: a skill body is arbitrarily long and -// most turns need none of it, so inlining every pinned body would burn -// the context window on instructions the model may never use. The model -// reads the index, decides, and pays for exactly the bodies it asks for. -export const AVAILABLE_SKILLS_OPEN_TAG = ""; -export const AVAILABLE_SKILLS_CLOSE_TAG = ""; - -export const SKILLS_LOAD_TOOL = "skills_load"; - -export type PinnedSkillIndexEntry = { - readonly name: string; - readonly description: string; -}; - -/** - * Renders the stanza. Returns the empty string when nothing is pinned, - * so a definition with no skills carries no stanza at all rather than an - * empty one the model has to reason about. - */ -export function buildAvailableSkillsStanza(entries: readonly PinnedSkillIndexEntry[]): string { - if (entries.length === 0) return ""; - const lines = entries.map((entry) => `- ${entry.name}: ${entry.description}`); - return [ - AVAILABLE_SKILLS_OPEN_TAG, - "These skills are available to you. Only their names and descriptions", - `are listed here. To read a skill's full instructions, call the`, - `\`${SKILLS_LOAD_TOOL}\` tool with that skill's name — never assume its`, - "contents from the description alone.", - "", - ...lines, - AVAILABLE_SKILLS_CLOSE_TAG, - ].join("\n"); -} - -const STANZA_PATTERN = new RegExp( - `\\n*${AVAILABLE_SKILLS_OPEN_TAG}[\\s\\S]*?${AVAILABLE_SKILLS_CLOSE_TAG}`, - "g", -); - -/** Removes any previously injected stanza, leaving the author's own prompt. */ -export function stripAvailableSkillsStanza(systemPrompt: string): string { - return systemPrompt.replace(STANZA_PATTERN, "").trimEnd(); -} - -/** - * Replaces the definition's stanza with one describing `entries`. Always - * strips first so re-pinning is idempotent — a definition pushed three - * times carries exactly one stanza, always the current one. - */ -export function withAvailableSkills( - systemPrompt: string, - entries: readonly PinnedSkillIndexEntry[], -): string { - const base = stripAvailableSkillsStanza(systemPrompt); - const stanza = buildAvailableSkillsStanza(entries); - if (stanza === "") return base; - return `${base}\n\n${stanza}`; -} diff --git a/tools/skills/src/query-client.test.ts b/tools/skills/src/query-client.test.ts deleted file mode 100644 index 337de010ff..0000000000 --- a/tools/skills/src/query-client.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { listSkills, loadSkill, searchSkills } from "./query-client"; - -const CONFIG = { - hubSkillsUrl: "https://hub.example", - sidecarToken: "sidecar-token", - runAddress: "run@runs.example", -}; - -// the workflow-skills HTTP surface these calls used to reach was -// deleted, and no stock Interchange route yet serves skill content. Every -// call fails closed with an explicit error rather than degrading to an -// empty registry. -describe("listSkills", () => { - test("fails closed naming the missing stock route", () => { - expect(listSkills(CONFIG)).rejects.toThrow(/no stock Interchange HTTP route/); - }); -}); - -describe("searchSkills", () => { - test("fails closed naming the missing stock route", () => { - expect(searchSkills(CONFIG, "issues")).rejects.toThrow(/no stock Interchange HTTP route/); - }); -}); - -describe("loadSkill", () => { - test("fails closed naming the missing stock route", () => { - expect(loadSkill(CONFIG, "triage")).rejects.toThrow(/no stock Interchange HTTP route/); - }); -}); diff --git a/tools/skills/src/query-client.ts b/tools/skills/src/query-client.ts deleted file mode 100644 index 5aefaf1ade..0000000000 --- a/tools/skills/src/query-client.ts +++ /dev/null @@ -1,51 +0,0 @@ -// the Workbench skill registry (`@corbits/skills`' HTTP surface -// this client used to call, mounted at `/api/workflow-skills`) was -// deleted — skills are native `kind:"skill"` hub assets now, and there is -// no stock Interchange route yet that lets a workflow-run bearer identity -// list a skill's index or read its `SKILL.md` body (the stock asset -// routes in `@intx/hub-api`'s `routes/assets.ts` cover asset metadata — -// id/name/displayName — and package-registry tarballs, but no skill -// content). Rather than vendor a replacement surface or silently degrade -// to "no skills", every call here fails closed with an explicit error -// naming the gap, exactly as this package's own design already commits -// to doing for any registry it cannot reach. -export interface WorkflowSkillsClientConfig { - readonly hubSkillsUrl: string; - readonly sidecarToken: string; - readonly runAddress: string; -} - -export type SkillIndexItem = { - readonly name: string; - readonly description: string; -}; - -export type LoadedSkill = SkillIndexItem & { readonly body: string }; - -const NO_STOCK_SKILL_CONTENT_ROUTE = - "Skill content has no stock Interchange HTTP route: the " + - "workbench-specific skills registry that used to serve it was removed, " + - "and no replacement has been added to @intx/hub-api yet."; - -/** Lists every skill the run's principal can see. */ -export function listSkills( - _config: WorkflowSkillsClientConfig, -): Promise { - return Promise.reject(new Error(NO_STOCK_SKILL_CONTENT_ROUTE)); -} - -/** Searches the visible skills by name and description. */ -export function searchSkills( - _config: WorkflowSkillsClientConfig, - _query: string, -): Promise { - return Promise.reject(new Error(NO_STOCK_SKILL_CONTENT_ROUTE)); -} - -/** Reads one skill's full instructions. */ -export function loadSkill( - _config: WorkflowSkillsClientConfig, - _name: string, -): Promise { - return Promise.reject(new Error(NO_STOCK_SKILL_CONTENT_ROUTE)); -} diff --git a/tools/skills/src/query-tools.test.ts b/tools/skills/src/query-tools.test.ts deleted file mode 100644 index 36d3682230..0000000000 --- a/tools/skills/src/query-tools.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { - SKILLS_LIST_TOOL, - SKILLS_LOAD_TOOL, - SKILLS_SEARCH_TOOL, - skillsQueryTools, -} from "./query-tools"; -import type { WorkflowSkillsToolEnv } from "./query-tools"; - -function testEnv(): WorkflowSkillsToolEnv { - return { - hubSkillsUrl: "https://hub.example.com", - sidecarToken: "sc-token", - address: "run_1@workflow", - } as unknown as WorkflowSkillsToolEnv; -} - -function callFor(name: string, args: Record = {}): ToolCall { - return { id: "call_1", name, arguments: args }; -} - -test("declares skills_list, skills_search, and skills_load", () => { - const bundle = skillsQueryTools(testEnv()); - expect(bundle.definitions.map((d) => d.name)).toEqual([ - SKILLS_LIST_TOOL, - SKILLS_SEARCH_TOOL, - SKILLS_LOAD_TOOL, - ]); -}); - -test("requires the sanctioned workflow-skills env keys, not a per-user credential", () => { - expect(skillsQueryTools.requires).toEqual(["hubSkillsUrl", "sidecarToken", "address"]); -}); - -test("no tool's input schema accepts a tenant or principal argument", () => { - const bundle = skillsQueryTools(testEnv()); - for (const definition of bundle.definitions) { - const properties = ( - definition as unknown as { - inputSchema?: { properties?: Record }; - } - ).inputSchema?.properties; - expect(properties?.["tenantId"]).toBeUndefined(); - expect(properties?.["principalId"]).toBeUndefined(); - } -}); - -// the registry HTTP surface this client used to call was -// deleted, and no stock Interchange route yet serves skill content, so -// every call fails closed rather than reaching a live registry. -test("skills_list surfaces the missing stock route as an error, never an empty list", async () => { - const result = await skillsQueryTools(testEnv()).run( - callFor(SKILLS_LIST_TOOL), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("no stock Interchange HTTP route"); -}); - -test("skills_search requires a query", async () => { - const result = await skillsQueryTools(testEnv()).run( - callFor(SKILLS_SEARCH_TOOL), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("requires a query"); -}); - -test("skills_load surfaces the missing stock route as an error rather than inventing one", async () => { - const result = await skillsQueryTools(testEnv()).run( - callFor(SKILLS_LOAD_TOOL, { name: "triage" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("no stock Interchange HTTP route"); -}); - -test("skills_load requires a name", async () => { - const result = await skillsQueryTools(testEnv()).run( - callFor(SKILLS_LOAD_TOOL), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("requires a skill name"); -}); - -test("load_skill is no longer a recognized tool name", async () => { - const result = await skillsQueryTools(testEnv()).run( - callFor("load_skill", { name: "triage" }), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("unknown tool"); -}); - -test("an unknown tool name is an error result, not a throw", async () => { - const result = await skillsQueryTools(testEnv()).run( - callFor("skills_delete"), - new AbortController().signal, - ); - expect(result.isError).toBe(true); - expect(String(result.content)).toContain("unknown tool"); -}); diff --git a/tools/skills/src/query-tools.ts b/tools/skills/src/query-tools.ts deleted file mode 100644 index 5f3e042fea..0000000000 --- a/tools/skills/src/query-tools.ts +++ /dev/null @@ -1,158 +0,0 @@ -// The `@corbits/tools-skills` bundle: `skills_list`, `skills_search`, -// and `skills_load` — the agent-facing half of the skill registry. A -// definition's pinned skills are already named in its system prompt's -// `` index (`@corbits/skills`' `withAvailableSkills`); -// `skills_load` is how the model turns one of those names into the actual -// instructions, so the index stays cheap and only the skills a turn -// truly needs are paid for in context. -// -// Fail-closed throughout: a transport, HTTP, auth, or shape failure -// comes back as a `ToolResult` with `isError: true` naming the failure. -// None of the three ever degrades to an empty list or an invented body — -// an agent told "no skills" behaves very differently from one told "the -// registry is unreachable", and only the second is honest. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; - -import { listSkills, loadSkill, searchSkills } from "./query-client"; -import { SKILLS_LOAD_TOOL } from "./prompt"; - -export const SKILLS_LIST_TOOL = "skills_list"; -export const SKILLS_SEARCH_TOOL = "skills_search"; -export { SKILLS_LOAD_TOOL }; - -/** Env this bundle needs beyond `BaseEnv`: the run's hub-reach credential. */ -export interface WorkflowSkillsToolEnv extends BaseEnv { - readonly hubSkillsUrl: string; - readonly sidecarToken: string; - readonly address: string; -} - -function errorResult(callId: string, err: unknown): ToolResult { - return { - callId, - isError: true, - content: err instanceof Error ? err.message : String(err), - }; -} - -function clientConfig(env: WorkflowSkillsToolEnv) { - return { - hubSkillsUrl: env.hubSkillsUrl, - sidecarToken: env.sidecarToken, - runAddress: env.address, - }; -} - -async function runSkillsList(env: WorkflowSkillsToolEnv, call: ToolCall): Promise { - try { - const skills = await listSkills(clientConfig(env)); - return { - callId: call.id, - isError: false, - content: JSON.stringify({ skills }), - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runSkillsSearch(env: WorkflowSkillsToolEnv, call: ToolCall): Promise { - const query = call.arguments["query"]; - if (typeof query !== "string" || query === "") { - return errorResult(call.id, new Error("skills_search requires a query")); - } - try { - const skills = await searchSkills(clientConfig(env), query); - return { - callId: call.id, - isError: false, - content: JSON.stringify({ skills }), - }; - } catch (err) { - return errorResult(call.id, err); - } -} - -async function runSkillsLoad(env: WorkflowSkillsToolEnv, call: ToolCall): Promise { - const name = call.arguments["name"]; - if (typeof name !== "string" || name === "") { - return errorResult(call.id, new Error("skills_load requires a skill name")); - } - try { - const skill = await loadSkill(clientConfig(env), name); - return { callId: call.id, isError: false, content: JSON.stringify(skill) }; - } catch (err) { - return errorResult(call.id, err); - } -} - -export const skillsQueryTools = defineTool({ - id: "@corbits/skills-tools/skills-query", - requires: ["hubSkillsUrl", "sidecarToken", "address"], - definitions: [ - { name: SKILLS_LIST_TOOL }, - { name: SKILLS_SEARCH_TOOL }, - { name: SKILLS_LOAD_TOOL }, - ], - factory: (env) => ({ - definitions: [ - { - name: SKILLS_LIST_TOOL, - description: - "Lists every skill available in this workbench — name and " + - "description only. Returns an error result naming the failure " + - "when the registry is unreachable; that is never the same as " + - "the workbench having no skills.", - inputSchema: { type: "object", properties: {} }, - }, - { - name: SKILLS_SEARCH_TOOL, - description: - "Searches this workbench's skills by name and description. " + - "Use it to find a skill for a task before deciding you must " + - "improvise one.", - inputSchema: { - type: "object", - properties: { - query: { type: "string", description: "What to search for." }, - }, - required: ["query"], - }, - }, - { - name: SKILLS_LOAD_TOOL, - description: - "Reads one skill's full instructions by name. Call this " + - "before following a skill listed in — the " + - "index carries only a description, never the instructions " + - "themselves.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "The skill's name, exactly as listed.", - }, - }, - required: ["name"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - switch (call.name) { - case SKILLS_LIST_TOOL: - return runSkillsList(env, call); - case SKILLS_SEARCH_TOOL: - return runSkillsSearch(env, call); - case SKILLS_LOAD_TOOL: - return runSkillsLoad(env, call); - default: - return Promise.resolve( - errorResult(call.id, new Error(`@corbits/tools-skills: unknown tool "${call.name}"`)), - ); - } - }, - }), -}); diff --git a/tools/skills/src/skill-md.test.ts b/tools/skills/src/skill-md.test.ts deleted file mode 100644 index 961a0e5411..0000000000 --- a/tools/skills/src/skill-md.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildSkillMd, parseSkillMd, SkillContentError, decodeSkillMd } from "./skill-md"; - -describe("buildSkillMd", () => { - test("round-trips name, description, scope, and body", () => { - const md = buildSkillMd({ - name: "summarize-transcript", - description: "Condenses a meeting transcript into decisions and owners.", - body: "1. Read the transcript.\n2. List every decision.", - scope: "tenant", - }); - const parsed = parseSkillMd(md); - expect(parsed.name).toBe("summarize-transcript"); - expect(parsed.description).toBe("Condenses a meeting transcript into decisions and owners."); - expect(parsed.scope).toBe("tenant"); - expect(parsed.body).toBe("1. Read the transcript.\n2. List every decision."); - }); - - test("a private scope survives the round trip", () => { - const md = buildSkillMd({ - name: "triage", - description: "Sorts issues.", - body: "Sort them.", - scope: "private", - }); - expect(parseSkillMd(md).scope).toBe("private"); - }); - - test("rejects a scope outside the private/tenant vocabulary", () => { - expect(() => - buildSkillMd({ - name: "triage", - description: "Sorts issues.", - body: "Sort them.", - scope: "everyone", - }), - ).toThrow(SkillContentError); - }); - - test("quotes a description containing a colon so the frontmatter stays one mapping", () => { - const md = buildSkillMd({ - name: "triage", - description: "Sorts issues: bug, question, or feature.", - body: "Sort them.", - scope: "private", - }); - expect(parseSkillMd(md).description).toBe("Sorts issues: bug, question, or feature."); - }); - - test("preserves an apostrophe through the single-quoted scalar", () => { - const md = buildSkillMd({ - name: "triage", - description: "Reads the reporter's own words first.", - body: "Sort them.", - scope: "private", - }); - expect(parseSkillMd(md).description).toBe("Reads the reporter's own words first."); - }); - - test("rejects a name the hub's skill kind handler would reject", () => { - expect(() => - buildSkillMd({ - name: "Summarize Transcript", - description: "Anything.", - body: "Body.", - scope: "private", - }), - ).toThrow(SkillContentError); - }); - - test("rejects the reserved vendor names", () => { - expect(() => - buildSkillMd({ - name: "claude", - description: "Anything.", - body: "Body.", - scope: "private", - }), - ).toThrow(SkillContentError); - }); - - test("rejects a description carrying HTML tags", () => { - expect(() => - buildSkillMd({ - name: "triage", - description: "Sorts issues.", - body: "Body.", - scope: "private", - }), - ).toThrow(SkillContentError); - }); - - test("rejects an empty body", () => { - expect(() => - buildSkillMd({ - name: "triage", - description: "Sorts.", - body: " ", - scope: "private", - }), - ).toThrow(SkillContentError); - }); -}); - -describe("parseSkillMd", () => { - test("rejects content with no frontmatter", () => { - expect(() => parseSkillMd("just a body")).toThrow(SkillContentError); - }); - - test("rejects frontmatter with no closing delimiter", () => { - expect(() => parseSkillMd("---\nname: triage\n")).toThrow(SkillContentError); - }); - - test("rejects frontmatter missing a description", () => { - expect(() => parseSkillMd("---\nname: triage\n---\nBody")).toThrow(SkillContentError); - }); - - test("a SKILL.md written before scope moved into frontmatter reads as private", () => { - const parsed = parseSkillMd("---\nname: triage\ndescription: Sorts issues.\n---\nBody"); - expect(parsed.scope).toBe("private"); - }); - - test("ignores optional frontmatter keys the Claude Code superset allows", () => { - const parsed = parseSkillMd( - "---\nname: triage\ndescription: Sorts issues.\nmodel: any\n---\nBody", - ); - expect(parsed.name).toBe("triage"); - expect(parsed.body).toBe("Body"); - }); - - test("decodeSkillMd reads raw asset bytes", () => { - const bytes = new TextEncoder().encode( - "---\nname: triage\ndescription: Sorts issues.\n---\nBody", - ); - expect(decodeSkillMd(bytes).name).toBe("triage"); - }); -}); diff --git a/tools/skills/src/skill-md.ts b/tools/skills/src/skill-md.ts deleted file mode 100644 index d68091677c..0000000000 --- a/tools/skills/src/skill-md.ts +++ /dev/null @@ -1,164 +0,0 @@ -// SKILL.md is the registry's only content format: the hub's native -// `skill` RepoKind handler (vendor/intx/hub-sessions/src/skill-kind.ts) -// walks each top-level directory of a skill asset, requires a SKILL.md -// there, and rejects the push unless the YAML frontmatter carries a -// kebab-case `name` matching the directory and a 1..1024-char -// `description` free of HTML tags. This package always writes a `scope` -// key as well; the handler ignores undeclared keys, so SKILL.md files -// written before scope moved into the frontmatter still validate. -// -// Both halves of that contract are enforced here, at this package's own -// boundary, so an author sees a plain-language rejection in the create -// dialog instead of a git push failure three layers down. The schema is -// re-declared rather than imported from the vendored handler because -// the handler exports it for its own push-validation path, not as a -// stable authoring contract — but the rules are deliberately identical, -// and a drift shows up immediately as a rejected push. -import { type } from "arktype"; - -export const SKILL_MD_FILENAME = "SKILL.md"; - -const FRONTMATTER_DELIMITER = "---"; - -const RESERVED_SKILL_NAMES = ["anthropic", "claude"]; - -export const skillNameSchema = type(/^[a-z0-9]+(-[a-z0-9]+)*$/) - .and("string<=64") - .narrow((name: string, ctx) => { - if (RESERVED_SKILL_NAMES.includes(name)) { - return ctx.mustBe(`not the reserved name "anthropic" or "claude"`); - } - return true; - }); - -export const skillDescriptionSchema = type("1 <= string <= 1024").and(type(/^(?!.*<[^>]+>).*$/s)); - -/** Who may see a skill: `private` (only its creator) or `tenant` (every - * principal in the owning workbench). Lives in the SKILL.md frontmatter — - * the asset's own bytes — so visibility travels with the content it gates - * instead of in a side table that can drift from it. */ -export const skillScopeSchema = type("'private' | 'tenant'"); - -export type SkillScope = typeof skillScopeSchema.infer; - -export const skillFrontmatterSchema = type({ - name: skillNameSchema, - description: skillDescriptionSchema, - "scope?": skillScopeSchema, -}).onUndeclaredKey("ignore"); - -export type SkillFrontmatter = typeof skillFrontmatterSchema.infer; - -export type ParsedSkillMd = { - readonly name: string; - readonly description: string; - readonly scope: SkillScope; - readonly body: string; -}; - -export class SkillContentError extends Error { - constructor(message: string) { - super(message); - this.name = "SkillContentError"; - } -} - -/** - * Renders a SKILL.md the hub's skill kind handler accepts. The - * description is emitted as a single-quoted YAML scalar so a colon or a - * leading `#` in an author's prose cannot re-open the frontmatter as a - * different mapping. Scope is required — a skill is never written without - * saying who may see it — and validated against the same vocabulary the - * parser accepts. - */ -export function buildSkillMd(input: { - readonly name: string; - readonly description: string; - readonly scope: string; - readonly body: string; -}): string { - const frontmatter = skillFrontmatterSchema({ - name: input.name, - description: input.description, - scope: input.scope, - }); - if (frontmatter instanceof type.errors) { - throw new SkillContentError(`skill frontmatter is invalid: ${frontmatter.summary}`); - } - const body = input.body.trim(); - if (body === "") { - throw new SkillContentError("skill body must not be empty"); - } - const quotedDescription = `'${frontmatter.description.replace(/'/g, "''")}'`; - return [ - FRONTMATTER_DELIMITER, - `name: ${frontmatter.name}`, - `description: ${quotedDescription}`, - `scope: ${frontmatter.scope}`, - FRONTMATTER_DELIMITER, - "", - body, - "", - ].join("\n"); -} - -/** - * Parses a SKILL.md back into the fields the registry surfaces. - * Fails closed: a missing or malformed frontmatter block is an error, - * never a skill that silently reads as nameless. A SKILL.md written - * before scope moved into the frontmatter carries no `scope` key and - * reads as `private` — the pre-cutover store's own default for a skill - * with no row — so old bytes stay invisible to everyone but their author - * instead of failing to parse. - * - * No backfill for the dropped `skill_access` table: every pre-cutover - * SKILL.md lacks `scope`, and no seed, fixture, template, or - * provisioning path plants a tenant-visible row (verified), so - * there is nothing that would silently narrow. Pre-GA cutovers are not - * migrated — dev databases reseed via `bun run reset` (ruling). */ -export function parseSkillMd(text_: string): ParsedSkillMd { - const lines = text_.split(/\r?\n/); - if (lines[0] !== FRONTMATTER_DELIMITER) { - throw new SkillContentError(`${SKILL_MD_FILENAME} is missing its YAML frontmatter delimiter`); - } - let endIdx = -1; - for (let i = 1; i < lines.length; i += 1) { - if (lines[i] === FRONTMATTER_DELIMITER) { - endIdx = i; - break; - } - } - if (endIdx === -1) { - throw new SkillContentError(`${SKILL_MD_FILENAME} frontmatter has no closing delimiter`); - } - const yamlText = lines.slice(1, endIdx).join("\n"); - let rawFrontmatter: unknown; - try { - rawFrontmatter = Bun.YAML.parse(yamlText); - } catch (cause) { - throw new SkillContentError( - `${SKILL_MD_FILENAME} frontmatter is not valid YAML: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - ); - } - const frontmatter = skillFrontmatterSchema(rawFrontmatter); - if (frontmatter instanceof type.errors) { - throw new SkillContentError( - `${SKILL_MD_FILENAME} frontmatter is invalid: ${frontmatter.summary}`, - ); - } - return { - name: frontmatter.name, - description: frontmatter.description, - scope: frontmatter.scope ?? "private", - body: lines - .slice(endIdx + 1) - .join("\n") - .trim(), - }; -} - -export function decodeSkillMd(bytes: Uint8Array): ParsedSkillMd { - return parseSkillMd(new TextDecoder().decode(bytes)); -} diff --git a/tools/skills/tsconfig.json b/tools/skills/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/skills/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/skills/tsconfig.src.json b/tools/skills/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/tools/skills/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tools/workflow-authoring/LICENSE b/tools/workflow-authoring/LICENSE deleted file mode 100644 index 173c3a21d0..0000000000 --- a/tools/workflow-authoring/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; version 2.1 of the License. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/tools/workflow-authoring/README.md b/tools/workflow-authoring/README.md deleted file mode 100644 index 849cfa6151..0000000000 --- a/tools/workflow-authoring/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# @corbits/workflow-authoring-tools - -The `@intx/agent` tool bundle over `@corbits/workflows`'s `./authoring`'s -workflow-run-authenticated routes: an agent writes an ordinary -workflow code package into a `kind: "workflow"` hub asset, republishes it, -and reads it back. See [docs/workflow-source-authoring.md](../../docs/workflow-source-authoring.md) -for the contract this implements. - -## The tools - -Only `workflow_deploy` is `approval: "ask"`: storing source is not a side -effect, but making a workflow runnable is. - -- `workflow_author({ name, files, message? })` — creates the asset and - commits the tree; returns `{ assetId, name, commitSha }`. `name` is - lowercase-kebab and unique per tenant (a duplicate is a 409). -- `workflow_republish({ assetId, files, message?, expectedHeadSha? })` — - commits a new version of the whole package. With `expectedHeadSha`, a - moved `refs/heads/main` is refused with 409 and the current head; the - agent re-reads and retries. Writes are additive: a path omitted keeps its - committed content. -- `workflow_source_read({ assetId })` — every file on `refs/heads/main` plus - `headSha`, as JSON. -- `wf_deploy_preview({ assetId, commitSha, entry })` — a static read of the - already-committed source: package name, file list, and any statically - declared `toolPackagePins`. Never installs, probes, gates, or freezes. -- `workflow_deploy({ assetId, commitSha, entry, packageName?, toolPackagePins? })` - — deploys the committed source through Interchange's native pipeline behind - a human approval. `packageName`/`toolPackagePins` come from a prior - `wf_deploy_preview` on the same commit and are shown on the approval card; - they are not sent to the hub. - -Every request carries the run's own sidecar bearer token and -`x-workflow-run-address`; the hub resolves tenant and principal from the run -and authorizes `asset:*`/`create`, `asset:`/`write`, or -`asset:`/`read` against the grant store before anything reaches git. The -hub also validates the tree at the boundary (repo-relative paths, no -secret-like filenames, `package.json` declaring an `interchange.workflow` -entry the tree carries, size caps) and returns a message the model can act -on. - -## Routes and client - -| Tool | Route | Client function | -| ---------------------- | --------------------------------------------------------------- | ----------------------- | -| `workflow_author` | `POST /api/workflow-workflow-authoring/author` | `authorWorkflow` | -| `workflow_republish` | `POST /api/workflow-workflow-authoring/republish` | `republishWorkflow` | -| `workflow_source_read` | `GET /api/workflow-workflow-authoring/:assetId/source` | `readWorkflowSource` | -| `wf_deploy_preview` | `POST /api/workflow-workflow-authoring/:assetId/deploy/preview` | `previewDeployWorkflow` | -| `workflow_deploy` | `POST /api/tenants/:tenantId/workflows/deployments` (stock) | `deployWorkflow` | - -`workflow_deploy` speaks stock Interchange routes: it reads the -tenant's resolved inference catalog from `GET /api/tenants/:tenantId/models`, -flattens it into one priority-ordered `sourceOfferingIds` chain, and posts an -`asset`/`source` deploy naming the commit. The run bearer authenticates both. - -The four authoring operations still call the Workbench-specific -`/api/workflow-workflow-authoring` mount. Their stock equivalent is git -smart-HTTP on the asset repo, which no run-bearer credential reaches today: -`createGitTokenAuth` accepts only an `itx_pat_`/`itx_svc_` git token, and the -stock mint route (`POST /api/tenants/:tenantId/git-tokens`) refuses a caller -with no browser session. carries that upstream ask. - -A hub refusal surfaces as `WorkflowAuthoringRequestError` (`status`, `code`, -`currentHeadSha` on a conflict); the bundle lets it throw, and -`@intx/agent`'s tool runner turns the message into an `isError` result. - -## Env - -`requires: ["hubWorkflowAuthoringUrl", "tenantId", "sidecarToken", "address"]` -— all four are threaded in -`apps/sidecar/src/workflow-substrate-factory/step-env.ts`; -`hubWorkflowAuthoringUrl` exactly like `hubCapabilitiesUrl`, `tenantId` from -the hub's signed deploy frame, as the `:tenantId` segment of the stock routes -`workflow_deploy` calls. - -## Bundle id - -`@corbits/workflow_authoring/wf`, not `@corbits/workflow-authoring-tools/…`: -the qualified `:` must fit OpenAI's 64-character wire cap after -`encodeToolName` escapes `@`, `/`, `:` and `-` to three characters each. - -## Running tests - -```sh -cd tools/workflow-authoring && bun test -``` - -Tests run against a mocked fetch; no `DATABASE_URL` or live hub is required. diff --git a/tools/workflow-authoring/package.json b/tools/workflow-authoring/package.json deleted file mode 100644 index bd01acab45..0000000000 --- a/tools/workflow-authoring/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@corbits/workflow-authoring-tools", - "version": "0.0.8", - "description": "Myra's workflow-authoring tool bundle (workflow_author, workflow_republish, workflow_source_read, workflow_deploy_preview, workflow_deploy): an @intx/agent tool bundle that writes a workflow code package into a kind:\"workflow\" hub asset, reads it back and previews a deploy's grant surface through @corbits/workflows's `./authoring`'s workflow-run-authenticated routes, and deploys it behind a human approval through Interchange's stock POST /api/tenants/:tenantId/workflows/deployments route", - "license": "LGPL-2.1-only", - "repository": { - "type": "git", - "url": "https://github.com/corbitsdev/workbench.git", - "directory": "tools/workflow-authoring" - }, - "files": [ - "src", - "LICENSE", - "README.md" - ], - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - }, - "peerDependencies": { - "@intx/agent": "0.3.0", - "@intx/types": "0.3.0" - } -} diff --git a/tools/workflow-authoring/src/client.test.ts b/tools/workflow-authoring/src/client.test.ts deleted file mode 100644 index 34540d8cea..0000000000 --- a/tools/workflow-authoring/src/client.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - authorWorkflow, - deployWorkflow, - orderedSourceOfferingIds, - readWorkflowSource, - republishWorkflow, - WorkflowAuthoringRequestError, - type WorkflowAuthoringClientConfig, -} from "./client"; - -type Seen = { url: string; init: RequestInit | undefined }; - -function capture(respond: () => Response): { - config: WorkflowAuthoringClientConfig; - seen: Seen[]; -} { - const seen: Seen[] = []; - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seen.push({ url: String(url), init }); - return respond(); - }) as unknown as typeof fetch; - return { - config: { - hubWorkflowAuthoringUrl: "https://hub.example.com", - tenantId: "tenant_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - }, - seen, - }; -} - -const FILES = { "package.json": "{}", "workflow.ts": "export default {};" }; - -test("authorWorkflow posts the tree to /author with the run's bearer token and address", async () => { - const { config, seen } = capture( - () => - new Response( - JSON.stringify({ - data: { - assetId: "asset_1", - name: "daily-digest", - commitSha: "sha_1", - }, - }), - { status: 201 }, - ), - ); - const summary = await authorWorkflow(config, { - name: "daily-digest", - files: FILES, - }); - expect(summary).toEqual({ - assetId: "asset_1", - name: "daily-digest", - commitSha: "sha_1", - }); - const [request] = seen; - expect(request?.url).toBe("https://hub.example.com/api/workflow-workflow-authoring/author"); - const headers = request?.init?.headers as Record; - expect(headers["authorization"]).toBe("Bearer sc-token"); - expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); - expect(JSON.parse(String(request?.init?.body))).toEqual({ - name: "daily-digest", - files: FILES, - }); -}); - -test("republishWorkflow forwards expectedHeadSha and surfaces a 409 with the current head", async () => { - const { config, seen } = capture( - () => - new Response( - JSON.stringify({ - error: { - code: "conflict", - userMessage: "asset moved", - refId: "ref_1", - }, - currentHeadSha: "sha_current", - }), - { status: 409 }, - ), - ); - const err = await republishWorkflow(config, { - assetId: "asset_1", - files: FILES, - expectedHeadSha: "sha_stale", - }).catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthoringRequestError); - expect((err as WorkflowAuthoringRequestError).code).toBe("conflict"); - expect((err as WorkflowAuthoringRequestError).currentHeadSha).toBe("sha_current"); - expect(JSON.parse(String(seen[0]?.init?.body))).toMatchObject({ - expectedHeadSha: "sha_stale", - }); -}); - -test("readWorkflowSource GETs /:assetId/source and returns the snapshot", async () => { - const { config, seen } = capture( - () => - new Response( - JSON.stringify({ - data: { - assetId: "asset_1", - name: "daily-digest", - headSha: "sha_head", - files: FILES, - }, - }), - ), - ); - const snapshot = await readWorkflowSource(config, "asset_1"); - expect(snapshot.headSha).toBe("sha_head"); - expect(snapshot.files).toEqual(FILES); - expect(seen[0]?.url).toBe( - "https://hub.example.com/api/workflow-workflow-authoring/asset_1/source", - ); - expect(seen[0]?.init?.method).toBeUndefined(); -}); - -test("a hub rejection with an error envelope becomes a WorkflowAuthoringRequestError carrying the hub's message", async () => { - const { config } = capture( - () => - new Response( - JSON.stringify({ - error: { - code: "invalid", - userMessage: 'file "../x" may not contain a ".." segment', - refId: "ref_1", - }, - }), - { status: 400 }, - ), - ); - const err = await authorWorkflow(config, { - name: "x", - files: { "../x": "" }, - }).catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthoringRequestError); - expect((err as Error).message).toMatch(/"\.\." segment/); -}); - -test("a non-envelope failure is an honest error naming the status, never a fabricated result", async () => { - const { config } = capture(() => new Response("", { status: 502, statusText: "Bad Gateway" })); - await expect(readWorkflowSource(config, "asset_1")).rejects.toThrow(/502/); -}); - -test("a success body of the wrong shape is rejected", async () => { - const { config } = capture(() => new Response(JSON.stringify({ nonsense: true }))); - await expect(authorWorkflow(config, { name: "x", files: FILES })).rejects.toThrow( - /expected shape/, - ); -}); - -// The deleted `/api/workflow-workflow-authoring/:assetId/deploy` mirror -// resolved this chain server-side from `listVisibleOfferings`, sorted by -// priority. The stock route takes it from the caller, and the stock -// discovery route it is rebuilt from groups its offerings under each -// model — so the flattening has to restore one global priority order -// across models, not preserve the per-model grouping. -test("orderedSourceOfferingIds flattens the discovery response into one priority order across models", () => { - expect( - orderedSourceOfferingIds([ - { - offerings: [ - { offeringId: "off_slow", priority: 30 }, - { offeringId: "off_mid", priority: 20 }, - ], - }, - { offerings: [{ offeringId: "off_fast", priority: 10 }] }, - ]), - ).toEqual(["off_fast", "off_mid", "off_slow"]); -}); - -test("deployWorkflow reads the stock catalog, then posts an asset/source deploy to the stock route", async () => { - const seen: { url: string; init: RequestInit | undefined }[] = []; - const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seen.push({ url: String(url), init }); - return String(url).endsWith("/models") - ? new Response( - JSON.stringify([ - { offerings: [{ offeringId: "off_2", priority: 20 }] }, - { offerings: [{ offeringId: "off_1", priority: 10 }] }, - ]), - ) - : new Response( - JSON.stringify({ - id: "run_1", - tenantId: "tenant_1", - definitionAssetId: "asset_1", - status: "pending", - createdAt: "2026-01-01T00:00:00.000Z", - }), - { status: 201 }, - ); - }) as unknown as typeof fetch; - const config: WorkflowAuthoringClientConfig = { - hubWorkflowAuthoringUrl: "https://hub.example.com", - tenantId: "tenant_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - }; - - const result = await deployWorkflow(config, { - assetId: "asset_1", - commitSha: "sha_1", - entry: "./workflow.ts", - }); - - expect(result).toEqual({ - deploymentId: "run_1", - definitionAssetId: "asset_1", - status: "pending", - }); - expect(seen[0]?.url).toBe("https://hub.example.com/api/tenants/tenant_1/models"); - expect(seen[1]?.url).toBe("https://hub.example.com/api/tenants/tenant_1/workflows/deployments"); - expect(JSON.parse(String(seen[1]?.init?.body))).toEqual({ - source: { - kind: "asset", - assetId: "asset_1", - package: { format: "source", commitSha: "sha_1" }, - }, - entry: "./workflow.ts", - sourceOfferingIds: ["off_1", "off_2"], - defaultSourceOfferingId: "off_1", - }); - const headers = seen[1]?.init?.headers as Record; - expect(headers["authorization"]).toBe("Bearer sc-token"); - expect(headers["x-workflow-run-address"]).toBe("run_1@workflow"); -}); - -test("deployWorkflow surfaces the stock error envelope's message", async () => { - const fetchImpl = (async (url: string | URL) => - String(url).endsWith("/models") - ? new Response(JSON.stringify([{ offerings: [{ offeringId: "off_1", priority: 1 }] }])) - : new Response( - JSON.stringify({ - error: { code: "invalid_workflow", message: "entry not found" }, - }), - { status: 409 }, - )) as unknown as typeof fetch; - const err = await deployWorkflow( - { - hubWorkflowAuthoringUrl: "https://hub.example.com", - tenantId: "tenant_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - fetchImpl, - }, - { assetId: "asset_1", commitSha: "sha_1", entry: "./workflow.ts" }, - ).catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthoringRequestError); - expect((err as WorkflowAuthoringRequestError).code).toBe("invalid_workflow"); - expect((err as Error).message).toBe("entry not found"); -}); diff --git a/tools/workflow-authoring/src/client.ts b/tools/workflow-authoring/src/client.ts deleted file mode 100644 index 7e09e3b913..0000000000 --- a/tools/workflow-authoring/src/client.ts +++ /dev/null @@ -1,337 +0,0 @@ -// deployWorkflow speaks stock @intx/hub-api routes; the tenant id rides in -// the client config as a path segment only, never trusted as authority -// (the hub sets the acting tenant from the authenticated run alone). The -// other four calls still hit the Workbench-specific mount because no -// run-bearer credential can reach stock git smart-HTTP today. -import { type } from "arktype"; -import { runBearerHeaders, runBearerFetch, type RunBearerClientConfig } from "./run-bearer"; - -export interface WorkflowAuthoringClientConfig extends RunBearerClientConfig { - /** The hub's plain HTTP origin, the same value every other tool - * bundle's `hub*Url` env key carries. */ - readonly hubWorkflowAuthoringUrl: string; - /** The run's own tenant — the `:tenantId` segment of every stock route. */ - readonly tenantId: string; -} - -export type WorkflowSourceFiles = Readonly>; - -export type AuthorWorkflowRequest = { - readonly name: string; - readonly files: WorkflowSourceFiles; - readonly message?: string; -}; - -export type RepublishWorkflowRequest = { - readonly assetId: string; - readonly files: WorkflowSourceFiles; - readonly message?: string; - readonly expectedHeadSha?: string; -}; - -export type WorkflowAssetSummary = { - readonly assetId: string; - readonly name: string; - readonly commitSha: string; -}; - -export type WorkflowSourceSnapshot = { - readonly assetId: string; - readonly name: string; - readonly headSha: string; - readonly files: WorkflowSourceFiles; -}; - -export type DeployWorkflowRequest = { - readonly assetId: string; - readonly commitSha: string; - readonly entry: string; -}; - -export type DeployWorkflowPreviewRequest = { - readonly assetId: string; - readonly commitSha: string; - readonly entry: string; -}; - -export type WorkflowDeployResult = { - readonly deploymentId: string; - readonly definitionAssetId: string; - readonly status: string; -}; - -export type ToolPackagePin = { - readonly name: string; - readonly version: string; -}; - -export type WorkflowDeployPreviewResult = { - readonly commitSha: string; - readonly entry: string; - readonly files: readonly string[]; - readonly toolPackagePins: readonly ToolPackagePin[]; - readonly packageName: string; -}; - -/** The hub refused the request with a canonical error envelope. `code` - * is the envelope's code (`invalid`, `forbidden`, `not_found`, - * `conflict`, ...); `currentHeadSha` is set on a republish `conflict` so - * the caller can re-read and retry. */ -export class WorkflowAuthoringRequestError extends Error { - readonly status: number; - readonly code: string; - readonly currentHeadSha?: string; - constructor(status: number, code: string, message: string, currentHeadSha?: string) { - super(message); - this.name = "WorkflowAuthoringRequestError"; - this.status = status; - this.code = code; - if (currentHeadSha !== undefined) this.currentHeadSha = currentHeadSha; - } -} - -const ErrorResponse = type({ - error: { code: "string", userMessage: "string" }, - "currentHeadSha?": "string", -}); - -const SummaryResponse = type({ - data: { assetId: "string", name: "string", commitSha: "string" }, -}); - -const SnapshotResponse = type({ - data: { - assetId: "string", - name: "string", - headSha: "string", - files: "Record", - }, -}); - -/** The stock deploy route's response: the anchor run projected as a - * deployment record. */ -const StockDeployResponse = type({ - id: "string", - definitionAssetId: "string", - status: "string", -}); - -/** The stock model-discovery response, narrowed to the two fields the - * deploy's ordered offering chain is built from. */ -const DiscoveredModels = type({ - offerings: type({ offeringId: "string", priority: "number" }).array(), -}).array(); - -/** The stock error envelope (`{error: {code, message}}`), which the - * vendored routes use in place of the canonical `userMessage` shape. */ -const StockErrorResponse = type({ - error: { code: "string", message: "string" }, -}); - -const DeployPreviewResponse = type({ - data: { - commitSha: "string", - entry: "string", - files: "string[]", - toolPackagePins: type({ name: "string", version: "string" }).array(), - packageName: "string", - }, -}); - -function endpoint(config: WorkflowAuthoringClientConfig, path: string): string { - return `${config.hubWorkflowAuthoringUrl}/api/workflow-workflow-authoring${path}`; -} - -function stockEndpoint(config: WorkflowAuthoringClientConfig, path: string): string { - return `${config.hubWorkflowAuthoringUrl}/api/tenants/${encodeURIComponent(config.tenantId)}${path}`; -} - -async function throwForStockFailure(response: Response, operation: string): Promise { - const body: unknown = await response.json().catch(() => undefined); - const parsed = StockErrorResponse(body); - if (parsed instanceof type.errors) { - throw new Error(`${operation} failed: ${response.status} ${response.statusText}`); - } - throw new WorkflowAuthoringRequestError(response.status, parsed.error.code, parsed.error.message); -} - -/** - * The ordered catalog offering chain a deploy hands the hub. Rebuilt - * client-side (the stock route takes it from the caller, unlike the - * deleted server-side mirror), re-sorted by priority to restore the single - * global ordering, and deduplicated since the stock route rejects a - * repeat offeringId. - */ -export function orderedSourceOfferingIds( - models: readonly { readonly offerings: readonly OfferingOrdering[] }[], -): readonly string[] { - const flattened = models.flatMap((m) => [...m.offerings]); - flattened.sort((a, b) => a.priority - b.priority || a.offeringId.localeCompare(b.offeringId)); - return [...new Set(flattened.map((o) => o.offeringId))]; -} - -export type OfferingOrdering = { - readonly offeringId: string; - readonly priority: number; -}; - -async function throwForFailure(response: Response, operation: string): Promise { - const body: unknown = await response.json().catch(() => undefined); - const parsed = ErrorResponse(body); - if (parsed instanceof type.errors) { - throw new Error(`${operation} failed: ${response.status} ${response.statusText}`); - } - throw new WorkflowAuthoringRequestError( - response.status, - parsed.error.code, - parsed.error.userMessage, - parsed.currentHeadSha, - ); -} - -function parseOrThrow( - schema: (value: unknown) => T | type.errors, - body: unknown, - operation: string, -): T { - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new Error(`${operation} response did not match the expected shape: ${parsed.summary}`); - } - return parsed; -} - -export async function authorWorkflow( - config: WorkflowAuthoringClientConfig, - input: AuthorWorkflowRequest, -): Promise { - const doFetch = runBearerFetch(config); - const response = await doFetch(endpoint(config, "/author"), { - method: "POST", - headers: { - ...runBearerHeaders(config), - "content-type": "application/json", - }, - body: JSON.stringify(input), - }); - if (!response.ok) await throwForFailure(response, "Authoring a workflow"); - return parseOrThrow(SummaryResponse, await response.json(), "Authoring a workflow").data; -} - -export async function republishWorkflow( - config: WorkflowAuthoringClientConfig, - input: RepublishWorkflowRequest, -): Promise { - const doFetch = runBearerFetch(config); - const response = await doFetch(endpoint(config, "/republish"), { - method: "POST", - headers: { - ...runBearerHeaders(config), - "content-type": "application/json", - }, - body: JSON.stringify(input), - }); - if (!response.ok) { - await throwForFailure(response, "Republishing a workflow"); - } - return parseOrThrow(SummaryResponse, await response.json(), "Republishing a workflow").data; -} - -export async function deployWorkflow( - config: WorkflowAuthoringClientConfig, - input: DeployWorkflowRequest, -): Promise { - const doFetch = runBearerFetch(config); - - const catalogResponse = await doFetch(stockEndpoint(config, "/models"), { - headers: runBearerHeaders(config), - }); - if (!catalogResponse.ok) { - await throwForStockFailure(catalogResponse, "Reading the workbench's inference catalog"); - } - const models = parseOrThrow( - DiscoveredModels, - await catalogResponse.json(), - "Reading the workbench's inference catalog", - ); - const sourceOfferingIds = orderedSourceOfferingIds(models); - const defaultSourceOfferingId = sourceOfferingIds[0]; - if (defaultSourceOfferingId === undefined) { - throw new WorkflowAuthoringRequestError( - 409, - "invalid", - "No catalog offerings are visible to this workbench, so a workflow cannot be deployed", - ); - } - - const response = await doFetch(stockEndpoint(config, "/workflows/deployments"), { - method: "POST", - headers: { - ...runBearerHeaders(config), - "content-type": "application/json", - }, - body: JSON.stringify({ - source: { - kind: "asset", - assetId: input.assetId, - package: { format: "source", commitSha: input.commitSha }, - }, - entry: input.entry, - sourceOfferingIds, - defaultSourceOfferingId, - }), - }); - if (!response.ok) { - await throwForStockFailure(response, "Deploying a workflow"); - } - const deployment = parseOrThrow( - StockDeployResponse, - await response.json(), - "Deploying a workflow", - ); - return { - deploymentId: deployment.id, - definitionAssetId: deployment.definitionAssetId, - status: deployment.status, - }; -} - -export async function previewDeployWorkflow( - config: WorkflowAuthoringClientConfig, - input: DeployWorkflowPreviewRequest, -): Promise { - const doFetch = runBearerFetch(config); - const response = await doFetch( - endpoint(config, `/${encodeURIComponent(input.assetId)}/deploy/preview`), - { - method: "POST", - headers: { - ...runBearerHeaders(config), - "content-type": "application/json", - }, - body: JSON.stringify({ - commitSha: input.commitSha, - entry: input.entry, - }), - }, - ); - if (!response.ok) { - await throwForFailure(response, "Previewing a workflow deploy"); - } - return parseOrThrow(DeployPreviewResponse, await response.json(), "Previewing a workflow deploy") - .data; -} - -export async function readWorkflowSource( - config: WorkflowAuthoringClientConfig, - assetId: string, -): Promise { - const doFetch = runBearerFetch(config); - const response = await doFetch(endpoint(config, `/${encodeURIComponent(assetId)}/source`), { - headers: runBearerHeaders(config), - }); - if (!response.ok) { - await throwForFailure(response, "Reading a workflow's source"); - } - return parseOrThrow(SnapshotResponse, await response.json(), "Reading a workflow's source").data; -} diff --git a/tools/workflow-authoring/src/index.ts b/tools/workflow-authoring/src/index.ts deleted file mode 100644 index 7b2d62de88..0000000000 --- a/tools/workflow-authoring/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { - authorWorkflow, - deployWorkflow, - readWorkflowSource, - republishWorkflow, - WorkflowAuthoringRequestError, - type AuthorWorkflowRequest, - type DeployWorkflowRequest, - type RepublishWorkflowRequest, - type WorkflowAssetSummary, - type WorkflowAuthoringClientConfig, - type WorkflowDeployResult, - type WorkflowSourceFiles, - type WorkflowSourceSnapshot, -} from "./client"; -export { - workflowAuthoringTools, - WORKFLOW_AUTHOR_TOOL, - WORKFLOW_DEPLOY_TOOL, - WORKFLOW_REPUBLISH_TOOL, - WORKFLOW_SOURCE_READ_TOOL, - type WorkflowAuthoringEnv, -} from "./tool"; diff --git a/tools/workflow-authoring/src/run-bearer.ts b/tools/workflow-authoring/src/run-bearer.ts deleted file mode 100644 index c2a9e081ec..0000000000 --- a/tools/workflow-authoring/src/run-bearer.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Shared fetch plumbing for a run-authenticated tool client — every tool -// bundle that calls a hub route mounted behind a workflow-run -// authenticator (sidecar bearer token + run address, never a human -// session) needs the same two headers. Kept local to this package -// (rather than imported from Workbench's own `@corbits/workflows`, which -// is private and carries a full server dependency graph) so this bundle -// installs standalone in any Interchange-backed hub. -export interface RunBearerClientConfig { - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; -} - -/** The two headers every run-authenticated route resolves tenant and - * principal from — identity never rides in a request body. */ -export function runBearerHeaders(config: RunBearerClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -export function runBearerFetch(config: RunBearerClientConfig): typeof fetch { - return config.fetchImpl ?? fetch; -} diff --git a/tools/workflow-authoring/src/tool.test.ts b/tools/workflow-authoring/src/tool.test.ts deleted file mode 100644 index 6625ecfdd9..0000000000 --- a/tools/workflow-authoring/src/tool.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { - workflowAuthoringTools, - WORKFLOW_AUTHOR_TOOL, - WORKFLOW_DEPLOY_PREVIEW_TOOL, - WORKFLOW_DEPLOY_TOOL, - WORKFLOW_REPUBLISH_TOOL, - WORKFLOW_SOURCE_READ_TOOL, - type WorkflowAuthoringEnv, -} from "./tool"; - -function testEnv(): WorkflowAuthoringEnv { - return { - hubWorkflowAuthoringUrl: "https://hub.example.com", - tenantId: "tenant_1", - sidecarToken: "sc-token", - address: "run_1@workflow", - } as unknown as WorkflowAuthoringEnv; -} - -function call(name: string, args: Record): ToolCall { - return { id: "call_1", name, arguments: args }; -} - -async function withFetch( - impl: (url: string, init?: RequestInit) => Response, - body: () => Promise, -): Promise { - const original = globalThis.fetch; - globalThis.fetch = (async (url: string | URL, init?: RequestInit) => - impl(String(url), init)) as unknown as typeof fetch; - try { - return await body(); - } finally { - globalThis.fetch = original; - } -} - -test("declares the four no-approval tools with workflow_deploy alone behind approval: ask", () => { - expect(workflowAuthoringTools.definitions).toEqual([ - { name: WORKFLOW_AUTHOR_TOOL }, - { name: WORKFLOW_REPUBLISH_TOOL }, - { name: WORKFLOW_SOURCE_READ_TOOL }, - { name: WORKFLOW_DEPLOY_PREVIEW_TOOL }, - { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, - ]); - expect(workflowAuthoringTools.requires).toEqual([ - "hubWorkflowAuthoringUrl", - "tenantId", - "sidecarToken", - "address", - ]); -}); - -test("every description tells the model the package shape and that deploy is a separate step", () => { - const bundle = workflowAuthoringTools(testEnv()); - const byName = new Map( - bundle.definitions.map((definition) => [ - definition.name, - (definition as unknown as { description: string }).description, - ]), - ); - for (const name of [WORKFLOW_AUTHOR_TOOL, WORKFLOW_REPUBLISH_TOOL]) { - const description = byName.get(name) ?? ""; - expect(description).toContain('"interchange": { "workflow"'); - expect(description).toContain("defineWorkflow"); - expect(description).toContain("@intx/workflow"); - expect(description).toMatch(/separate/); - } -}); - -test("workflow_author rejects a call missing files without calling the hub", async () => { - const bundle = workflowAuthoringTools(testEnv()); - await withFetch( - () => { - throw new Error("must not be called"); - }, - async () => { - await expect( - bundle.run( - call(WORKFLOW_AUTHOR_TOOL, { name: "daily-digest" }), - new AbortController().signal, - ), - ).rejects.toThrow(/invalid input/); - }, - ); -}); - -test("workflow_author posts to the authoring route and reports asset id and commit", async () => { - const bundle = workflowAuthoringTools(testEnv()); - let seenUrl: string | undefined; - const result = await withFetch( - (url) => { - seenUrl = url; - return new Response( - JSON.stringify({ - data: { - assetId: "asset_1", - name: "daily-digest", - commitSha: "sha_1", - }, - }), - { status: 201 }, - ); - }, - () => - bundle.run( - call(WORKFLOW_AUTHOR_TOOL, { - name: "daily-digest", - files: { "package.json": "{}", "workflow.ts": "" }, - }), - new AbortController().signal, - ), - ); - expect(seenUrl).toBe("https://hub.example.com/api/workflow-workflow-authoring/author"); - expect(result.isError).toBe(false); - expect(result.content).toContain("asset_1"); - expect(result.content).toContain("sha_1"); - expect(result.content).toMatch(/until deployed/); -}); - -test("workflow_republish surfaces a head conflict with the current sha in the thrown message", async () => { - const bundle = workflowAuthoringTools(testEnv()); - await withFetch( - () => - new Response( - JSON.stringify({ - error: { - code: "conflict", - userMessage: - "workflow asset asset_1 moved: expected head sha_stale but refs/heads/main is at sha_current", - refId: "ref_1", - }, - currentHeadSha: "sha_current", - }), - { status: 409 }, - ), - async () => { - await expect( - bundle.run( - call(WORKFLOW_REPUBLISH_TOOL, { - assetId: "asset_1", - files: { "workflow.ts": "" }, - expectedHeadSha: "sha_stale", - }), - new AbortController().signal, - ), - ).rejects.toThrow(/sha_current/); - }, - ); -}); - -test("workflow_source_read returns the snapshot as JSON the model can parse", async () => { - const bundle = workflowAuthoringTools(testEnv()); - const snapshot = { - assetId: "asset_1", - name: "daily-digest", - headSha: "sha_head", - files: { "package.json": "{}" }, - }; - const result = await withFetch( - (url) => { - expect(url).toBe("https://hub.example.com/api/workflow-workflow-authoring/asset_1/source"); - return new Response(JSON.stringify({ data: snapshot })); - }, - () => - bundle.run( - call(WORKFLOW_SOURCE_READ_TOOL, { assetId: "asset_1" }), - new AbortController().signal, - ), - ); - expect(JSON.parse(String(result.content))).toEqual(snapshot); -}); - -test("workflow_deploy posts an asset/source deploy to the stock tenant route — packageName/toolPackagePins stay client-side for the approval headline", async () => { - const bundle = workflowAuthoringTools(testEnv()); - const seen: { url: string; body: unknown }[] = []; - const result = await withFetch( - (url, init) => { - seen.push({ - url, - body: init?.body !== undefined ? JSON.parse(String(init.body)) : undefined, - }); - return url.endsWith("/models") - ? new Response(JSON.stringify([{ offerings: [{ offeringId: "off_1", priority: 10 }] }])) - : new Response( - JSON.stringify({ - id: "run_1", - tenantId: "tenant_1", - definitionAssetId: "asset_1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }), - { status: 201 }, - ); - }, - () => - bundle.run( - call(WORKFLOW_DEPLOY_TOOL, { - assetId: "asset_1", - commitSha: "sha_1", - entry: "./workflow.ts", - packageName: "daily-digest", - toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], - }), - new AbortController().signal, - ), - ); - expect(seen[1]?.url).toBe("https://hub.example.com/api/tenants/tenant_1/workflows/deployments"); - // `packageName`/`toolPackagePins` are carried on the approval card via - // the tool call's own arguments (see @corbits/approvals' headline.ts), - // not re-sent to the hub — the stock deploy route names the commit - // through its `source` instead. - expect(seen[1]?.body).toEqual({ - source: { - kind: "asset", - assetId: "asset_1", - package: { format: "source", commitSha: "sha_1" }, - }, - entry: "./workflow.ts", - sourceOfferingIds: ["off_1"], - defaultSourceOfferingId: "off_1", - }); - expect(result.isError).toBe(false); - expect(result.content).toContain("run_1"); - expect(result.content).toContain("asset_1"); -}); - -test("workflow_deploy rejects a call missing required fields without calling the hub", async () => { - const bundle = workflowAuthoringTools(testEnv()); - await withFetch( - () => { - throw new Error("must not be called"); - }, - async () => { - await expect( - bundle.run( - call(WORKFLOW_DEPLOY_TOOL, { - assetId: "asset_1", - entry: "./workflow.ts", - }), - new AbortController().signal, - ), - ).rejects.toThrow(/invalid input/); - }, - ); -}); - -test("wf_deploy_preview posts assetId, commitSha, and entry to the preview route and never approval-gates", async () => { - const bundle = workflowAuthoringTools(testEnv()); - let seenUrl: string | undefined; - const result = await withFetch( - (url) => { - seenUrl = url; - return new Response( - JSON.stringify({ - data: { - commitSha: "sha_1", - entry: "./workflow.ts", - files: ["package.json", "workflow.ts"], - toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], - packageName: "daily-digest", - }, - }), - ); - }, - () => - bundle.run( - call(WORKFLOW_DEPLOY_PREVIEW_TOOL, { - assetId: "asset_1", - commitSha: "sha_1", - entry: "./workflow.ts", - }), - new AbortController().signal, - ), - ); - expect(seenUrl).toBe( - "https://hub.example.com/api/workflow-workflow-authoring/asset_1/deploy/preview", - ); - expect(result.isError).toBe(false); - expect(JSON.parse(String(result.content))).toEqual({ - commitSha: "sha_1", - entry: "./workflow.ts", - files: ["package.json", "workflow.ts"], - toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], - packageName: "daily-digest", - }); - expect( - workflowAuthoringTools.definitions.find((d) => d.name === WORKFLOW_DEPLOY_PREVIEW_TOOL) - ?.approval, - ).toBeUndefined(); -}); - -test("an unknown tool name rejects loudly, never a silent no-op", async () => { - const bundle = workflowAuthoringTools(testEnv()); - await expect( - bundle.run(call("delete_everything", {}), new AbortController().signal), - ).rejects.toThrow(/unknown tool/); -}); diff --git a/tools/workflow-authoring/src/tool.ts b/tools/workflow-authoring/src/tool.ts deleted file mode 100644 index d2ac58d0de..0000000000 --- a/tools/workflow-authoring/src/tool.ts +++ /dev/null @@ -1,374 +0,0 @@ -// The first three tools carry no approval: "ask" since writing source is not -// a side effect (docs/workflow-model.md, "Authority boundaries"); -// workflow_deploy does, since deploying makes a workflow selectable as a -// routine target. A thrown error surfaces as ToolResult{isError:true} so the -// model sees exactly what the hub refused. -import { defineTool } from "@intx/agent"; -import type { BaseEnv } from "@intx/agent"; -import type { ToolCall, ToolResult } from "@intx/types/runtime"; -import { type } from "arktype"; - -import { - authorWorkflow, - deployWorkflow, - previewDeployWorkflow, - readWorkflowSource, - republishWorkflow, - type WorkflowAuthoringClientConfig, -} from "./client"; - -export const WORKFLOW_AUTHOR_TOOL = "workflow_author"; -export const WORKFLOW_REPUBLISH_TOOL = "workflow_republish"; -export const WORKFLOW_SOURCE_READ_TOOL = "workflow_source_read"; -export const WORKFLOW_DEPLOY_PREVIEW_TOOL = "wf_deploy_preview"; -export const WORKFLOW_DEPLOY_TOOL = "workflow_deploy"; - -/** Env this bundle needs beyond `BaseEnv`: the hub origin under its own - * key plus the run's bearer token and address, threaded by - * `apps/sidecar/src/workflow-substrate-factory/step-env.ts` exactly the - * way `@corbits/capability-tools`' `hubCapabilitiesUrl` is. */ -export interface WorkflowAuthoringEnv extends BaseEnv { - readonly hubWorkflowAuthoringUrl: string; - /** The run's own tenant — the `:tenantId` segment of the stock deploy - * and model-discovery routes `workflow_deploy` calls. */ - readonly tenantId: string; - readonly sidecarToken: string; - readonly address: string; -} - -const Files = type("Record"); - -const AuthorInput = type({ - name: "string > 0", - files: Files, - "message?": "string > 0", -}); - -const RepublishInput = type({ - assetId: "string > 0", - files: Files, - "message?": "string > 0", - "expectedHeadSha?": "string > 0", -}); - -const SourceReadInput = type({ assetId: "string > 0" }); - -const DeployPreviewInput = type({ - assetId: "string > 0", - commitSha: "string > 0", - entry: "string > 0", -}); - -const ToolPackagePinInput = type({ name: "string > 0", version: "string > 0" }); - -const DeployInput = type({ - assetId: "string > 0", - commitSha: "string > 0", - entry: "string > 0", - "packageName?": "string > 0", - "toolPackagePins?": ToolPackagePinInput.array(), -}); - -const PACKAGE_SHAPE_DESCRIPTION = - "The package is an ordinary code package: a top-level package.json " + - 'with name, version, "type": "module", and ' + - '"interchange": { "workflow": "./workflow.ts" }; the entry module ' + - "default-exports defineWorkflow({ ... }) imported from " + - '"@intx/workflow"; plus any files the entry imports. File paths are ' + - 'repo-relative with "/" separators (no leading "/", no "..", no ' + - ".git/); secret-like names (.env*, *.pem, *.key, id_rsa*, *.p12) are " + - "refused, and never put credentials in source. "; - -const FILES_PROPERTY = { - type: "object", - additionalProperties: { type: "string" }, - description: - "Map of repo-relative file path to UTF-8 file contents, e.g. " + - '{ "package.json": "...", "workflow.ts": "..." }.', -} as const; - -function clientConfig(env: WorkflowAuthoringEnv): WorkflowAuthoringClientConfig { - return { - hubWorkflowAuthoringUrl: env.hubWorkflowAuthoringUrl, - tenantId: env.tenantId, - sidecarToken: env.sidecarToken, - address: env.address, - }; -} - -function invalidInput(tool: string, errors: type.errors): Error { - return new Error(`${tool} received invalid input: ${errors.summary}`); -} - -function textResult(callId: string, content: string): ToolResult { - return { callId, isError: false, content }; -} - -async function runAuthor(env: WorkflowAuthoringEnv, call: ToolCall): Promise { - const input = AuthorInput(call.arguments); - if (input instanceof type.errors) { - throw invalidInput(WORKFLOW_AUTHOR_TOOL, input); - } - const summary = await authorWorkflow(clientConfig(env), input); - return textResult( - call.id, - `Authored workflow "${summary.name}" as asset ${summary.assetId} at commit ${summary.commitSha}. ` + - "It is source only until deployed.", - ); -} - -async function runRepublish(env: WorkflowAuthoringEnv, call: ToolCall): Promise { - const input = RepublishInput(call.arguments); - if (input instanceof type.errors) { - throw invalidInput(WORKFLOW_REPUBLISH_TOOL, input); - } - const summary = await republishWorkflow(clientConfig(env), input); - return textResult( - call.id, - `Republished workflow "${summary.name}" (asset ${summary.assetId}) at commit ${summary.commitSha}. ` + - "A deployed copy keeps running the previously deployed commit until redeployed.", - ); -} - -async function runSourceRead(env: WorkflowAuthoringEnv, call: ToolCall): Promise { - const input = SourceReadInput(call.arguments); - if (input instanceof type.errors) { - throw invalidInput(WORKFLOW_SOURCE_READ_TOOL, input); - } - const snapshot = await readWorkflowSource(clientConfig(env), input.assetId); - return textResult(call.id, JSON.stringify(snapshot)); -} - -async function runDeployPreview(env: WorkflowAuthoringEnv, call: ToolCall): Promise { - const input = DeployPreviewInput(call.arguments); - if (input instanceof type.errors) { - throw invalidInput(WORKFLOW_DEPLOY_PREVIEW_TOOL, input); - } - const result = await previewDeployWorkflow(clientConfig(env), input); - return textResult(call.id, JSON.stringify(result)); -} - -async function runDeploy(env: WorkflowAuthoringEnv, call: ToolCall): Promise { - const input = DeployInput(call.arguments); - if (input instanceof type.errors) { - throw invalidInput(WORKFLOW_DEPLOY_TOOL, input); - } - const result = await deployWorkflow(clientConfig(env), { - assetId: input.assetId, - commitSha: input.commitSha, - entry: input.entry, - }); - return textResult( - call.id, - `Deployed workflow asset ${result.definitionAssetId} as deployment ${result.deploymentId} (status: ${result.status}). ` + - "It is now selectable as a routine target.", - ); -} - -/** id's middle segment is shortened (not the package name): `:` on the wire must fit OpenAI's 64-char cap. */ -export const workflowAuthoringTools = defineTool({ - id: "@corbits/workflow_authoring/wf", - requires: ["hubWorkflowAuthoringUrl", "tenantId", "sidecarToken", "address"], - definitions: [ - { name: WORKFLOW_AUTHOR_TOOL }, - { name: WORKFLOW_REPUBLISH_TOOL }, - { name: WORKFLOW_SOURCE_READ_TOOL }, - { name: WORKFLOW_DEPLOY_PREVIEW_TOOL }, - { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, - ], - factory: (env) => ({ - definitions: [ - { - name: WORKFLOW_AUTHOR_TOOL, - description: - "Write a new workflow code package into this workbench as a " + - "workflow asset and commit it. " + - PACKAGE_SHAPE_DESCRIPTION + - "Returns the asset id and commit sha. This only stores source: " + - "deploying the asset so it can run is a separate step that a " + - "human approves. The name must be unique in the workbench; a " + - "duplicate name is refused, so republish the existing asset " + - "instead.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: - "Lowercase-kebab asset name (letters, digits, hyphens), " + 'e.g. "daily-digest".', - }, - files: FILES_PROPERTY, - message: { - type: "string", - description: "Optional commit message.", - }, - }, - required: ["name", "files"], - }, - }, - { - name: WORKFLOW_REPUBLISH_TOOL, - description: - "Commit a new version of an existing workflow asset's source. " + - "Send the whole package (package.json and the entry module " + - "included): each file overwrites the same path, and a path you " + - "omit keeps its committed content — this tool cannot delete " + - "files. " + - PACKAGE_SHAPE_DESCRIPTION + - "Pass expectedHeadSha (the headSha from workflow_source_read) " + - "so a concurrent change is refused instead of overwritten; on a " + - "conflict, read the source again and retry. Returns the new " + - "commit sha. Deploying the new commit is a separate, " + - "human-approved step.", - inputSchema: { - type: "object", - properties: { - assetId: { - type: "string", - description: "The workflow asset id to update.", - }, - files: FILES_PROPERTY, - message: { - type: "string", - description: "Optional commit message.", - }, - expectedHeadSha: { - type: "string", - description: - "The head commit sha you last read; the write is refused " + - "if the asset has moved since.", - }, - }, - required: ["assetId", "files"], - }, - }, - { - name: WORKFLOW_SOURCE_READ_TOOL, - description: - "Read a workflow asset's committed source: every file on its " + - "main branch plus the head commit sha. Use it before " + - "workflow_republish to see the current tree and to obtain " + - "expectedHeadSha.", - inputSchema: { - type: "object", - properties: { - assetId: { - type: "string", - description: "The workflow asset id to read.", - }, - }, - required: ["assetId"], - }, - }, - { - name: WORKFLOW_DEPLOY_PREVIEW_TOOL, - description: - "Read what deploying a workflow asset's committed source would " + - "run, WITHOUT deploying it: a static read of the already-" + - "committed source at commitSha (never installs, probes, gates, " + - "or freezes anything). Returns the package name, the file list, " + - "and any toolPackagePins statically declared in the entry " + - "module. Call this BEFORE workflow_deploy and pass its " + - "packageName and toolPackagePins on that call, so the human " + - "approving the deploy sees the real package and tools it names.", - inputSchema: { - type: "object", - properties: { - assetId: { - type: "string", - description: "The workflow asset id to preview a deploy of.", - }, - commitSha: { - type: "string", - description: - "The exact commit to preview — the commitSha from " + - "workflow_author, workflow_republish, or " + - "workflow_source_read's headSha.", - }, - entry: { - type: "string", - description: 'The interchange.workflow entry module path, e.g. "./workflow.ts".', - }, - }, - required: ["assetId", "commitSha", "entry"], - }, - }, - { - name: WORKFLOW_DEPLOY_TOOL, - description: - "Deploy a workflow asset's committed source through " + - "Interchange's native deploy pipeline (install, probe, capability " + - "walk, gate, freeze), making it selectable as a routine target. " + - "A human must approve this before it runs: the approval card " + - "shows the package and tools this call names, sourced from a " + - "prior wf_deploy_preview call on the same commit — call " + - "that FIRST and pass its packageName and toolPackagePins here " + - "so the approval reflects the real committed source, not just " + - "an asset id. Grants/capabilities are stamped by the native " + - "install+probe+gate this call runs, which the human does not " + - "see a preview of yet; say so if you explain this " + - "approval to a human. Inference sources come from the " + - "workbench's own catalog — never pass a model or credential.", - inputSchema: { - type: "object", - properties: { - assetId: { - type: "string", - description: "The workflow asset id to deploy.", - }, - commitSha: { - type: "string", - description: - "The exact commit to deploy — the commitSha from " + - "workflow_author, workflow_republish, or " + - "workflow_source_read's headSha.", - }, - entry: { - type: "string", - description: 'The interchange.workflow entry module path, e.g. "./workflow.ts".', - }, - packageName: { - type: "string", - description: - "The packageName returned by wf_deploy_preview for " + - "this same asset/commit, shown on the approval card.", - }, - toolPackagePins: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - version: { type: "string" }, - }, - required: ["name", "version"], - }, - description: - "The toolPackagePins returned by wf_deploy_preview " + - "for this same asset/commit, shown on the approval card.", - }, - }, - required: ["assetId", "commitSha", "entry"], - }, - }, - ], - run: (call: ToolCall, _signal: AbortSignal) => { - switch (call.name) { - case WORKFLOW_AUTHOR_TOOL: - return runAuthor(env, call); - case WORKFLOW_REPUBLISH_TOOL: - return runRepublish(env, call); - case WORKFLOW_SOURCE_READ_TOOL: - return runSourceRead(env, call); - case WORKFLOW_DEPLOY_PREVIEW_TOOL: - return runDeployPreview(env, call); - case WORKFLOW_DEPLOY_TOOL: - return runDeploy(env, call); - default: - return Promise.reject( - new Error(`@corbits/workflow-authoring-tools: unknown tool "${call.name}"`), - ); - } - }, - }), -}); diff --git a/tools/workflow-authoring/tsconfig.json b/tools/workflow-authoring/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/tools/workflow-authoring/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/tools/workflow-authoring/tsconfig.src.json b/tools/workflow-authoring/tsconfig.src.json deleted file mode 100644 index d84257c8f8..0000000000 --- a/tools/workflow-authoring/tsconfig.src.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true, - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/tsconfig.build.json b/tsconfig.build.json index 166c04807c..16faa6dc23 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -16,36 +16,12 @@ { "path": "./packages/error-sink/tsconfig.src.json" }, - { - "path": "./packages/inference-catalog/tsconfig.src.json" - }, { "path": "./packages/url-path/tsconfig.src.json" }, { "path": "./packages/workflows/tsconfig.src.json" }, - { - "path": "./tools/access/tsconfig.src.json" - }, - { - "path": "./tools/agent-directory/tsconfig.src.json" - }, - { - "path": "./tools/capability/tsconfig.src.json" - }, - { - "path": "./tools/catalog/tsconfig.src.json" - }, - { - "path": "./tools/interaction/tsconfig.src.json" - }, - { - "path": "./tools/skills/tsconfig.src.json" - }, - { - "path": "./tools/workflow-authoring/tsconfig.src.json" - }, { "path": "./vendor/intx/agent/tsconfig.src.json" },