diff --git a/.changeset/retire-agent-generator.md b/.changeset/retire-agent-generator.md new file mode 100644 index 0000000000..1000ac17d8 --- /dev/null +++ b/.changeset/retire-agent-generator.md @@ -0,0 +1,66 @@ +--- +"@objectstack/cli": minor +--- + +fix(cli): **BREAKING** — the `agent` generator is retired, and `os g agent` now says why and points at skills (ADR-0063 §2, #10359) + +**⛔ If a script, a Makefile or a CI step in your project runs `os g agent`, it +will now exit 1.** That is the intended outcome and the one way this change can +interrupt you: the command is gone, deliberately, and the failure is how you +find out. Everything it used to produce was already being discarded — read on. + +`minor`, not `major`: during the launch window this stack ships breaking changes +as `minor` (pre-1.0 semantics under lockstep versioning — see +`scripts/check-changeset-no-major.mjs`). + +**What the command actually did.** `os g agent ` scaffolded a typed +`AI.Agent` into `src/agents/`. Per ADR-0063 §2 (which reversed ADR-0040 §3) the +kernel ships exactly **two** agents — `ask` and `build` — bound by surface and +never picked from a roster, and the runtime catalog **filters out every +non-platform agent record**. So the scaffolded file parsed, passed +`os validate`, published without complaint, and then never appeared anywhere. +No error at any step. An author who followed the documented example got a file, +a green validate, a successful publish, and nothing to show for it. + +**Why the roster entry was not simply deleted.** A deleted type falls through to +`Unknown type: agent` plus a list of what is left, which tells the author their +spelling is not on the list and invites them to hunt for the right spelling of +something that no longer exists — the same silence, one step earlier. `agent` is +now a **retirement ledger entry** instead, and the refusal carries both halves: +the decision that withdrew the surface, and the surface to author in its place. +What you see: + +``` + ✗ `os g agent` was retired — agents are platform-internal (ADR-0063 §2). + + The kernel ships exactly two agents, `ask` and `build`, bound by surface. + An agent you author still parses and still publishes — and the runtime + catalog then filters it out, so it never appears and nothing tells you. + This command scaffolded exactly that file, so it is retired, not repaired. + + Author a SKILL instead. Skills (plus tools / MCP) are the third-party + extension primitive ADR-0063 names — the live surface this one was not. + + There is no `os g skill` scaffolder yet. Write the file by hand: + + src/skills/.skill.ts + import { defineSkill } from '@objectstack/spec/ai'; + + Docs: https://objectstack.ai/docs/ai/agents +``` + +**There is no command to rewrite the call into.** A skill is a different +artifact with a different schema, not a renamed agent, and `os g skill` does not +exist yet — the skill scaffolder is tracked separately, and this message says so +rather than promising it. Delete the call; author the skill file by hand. + +The generator roster is now `object`, `view`, `action`, `flow`, `dashboard`, +`app`. The docs that advertised the retired one — the `os g agent support` +example, the `agent` / `src/agents/` row of the Available types table, and +`os g agent sales-assistant` in the Typical Workflow block — are gone from +`content/docs/deployment/cli.mdx`, which carries the retirement note instead; +`packages/cli/README.md`'s type roster follows. The quick-start project-layout +map, which listed `src/agents` as the directory an app author writes AI metadata +into, now names `src/skills`. + + diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index b2db97142e..8e12d67573 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1027,7 +1027,6 @@ os g object customer # Generate a Customer object os g view customer # Generate a Customer list view os g action approve # Generate an action os g flow customer # Generate an automation flow -os g agent support # Generate an AI agent os g dashboard sales # Generate a dashboard os g app crm # Generate an app definition @@ -1043,10 +1042,23 @@ os g object task --dry-run # Preview without writing | `view` | `src/views/` | List or form view definition | | `action` | `src/actions/` | Button or batch action | | `flow` | `src/flows/` | Automation flow | -| `agent` | `src/agents/` | AI agent | | `dashboard` | `src/dashboards/` | Analytics dashboard | | `app` | `src/apps/` | Application navigation | + +There is no `agent` type. Running `os g agent ` fails with a message +naming [ADR-0063](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0063-two-kernel-agents-skills-are-the-extension-primitive.md) +and pointing at skills, rather than the generic "unknown type" listing. + +Agents are **platform-internal**: the kernel ships exactly two (`ask` and +`build`), and the runtime catalog filters out every other agent record. A +scaffolded `src/agents/*.ts` therefore passed `os validate`, published without +complaint, and never appeared — silently. Skills (plus tools / MCP) are the +third-party extension primitive, authored as `src/skills/.skill.ts` with +`defineSkill`; see [AI Agents](/docs/ai/agents). There is no `os g skill` +scaffolder yet, so write that file by hand for now. + + **Options:** - `-d, --dir ` — Override target directory - `--dry-run` — Preview without writing files @@ -1583,7 +1595,6 @@ os g object opportunity # 3. Add business logic os g flow lead-qualification -os g agent sales-assistant # 4. Validate everything os validate diff --git a/content/docs/getting-started/quick-start.mdx b/content/docs/getting-started/quick-start.mdx index 16c09fbb9d..612137850b 100644 --- a/content/docs/getting-started/quick-start.mdx +++ b/content/docs/getting-started/quick-start.mdx @@ -37,7 +37,7 @@ a project: | **Automation** | Flows, workflows, triggers, approvals (`src/flows`) | Event handlers, approval chains, scheduled jobs | | **Interface** | Views, apps, dashboards, actions (`src/views`, `src/apps`) | The Console UI, navigation, responsive layout | | **Access** | Roles, permissions, sharing, row-level security | Middleware, RLS policies, field masking | -| **AI** | Agents, tools, RAG, MCP exposure (`src/agents`) | Chat, search indexes, an MCP server | +| **AI** | Skills, tools, RAG, MCP exposure (`src/skills`) | Chat, search indexes, an MCP server | The through-line: **you author intent once as metadata, and the runtime derives the database, the API, the UI, and the AI tool surface from it.** That's why an agent diff --git a/packages/cli/README.md b/packages/cli/README.md index 5c1a68f934..8b88656e12 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -59,7 +59,13 @@ os compile | `os generate ` | Generate metadata files (alias: `os g`) | | `os create [name]` | Create a new package/plugin/example from template | -Available generate types: `object`, `view`, `action`, `flow`, `agent`, `dashboard`, `app` +Available generate types: `object`, `view`, `action`, `flow`, `dashboard`, `app` + +`agent` is **retired** (ADR-0063 §2): agents are platform-internal, so a scaffolded +`src/agents/*.ts` validated, published and was then filtered out of the runtime +catalog without a word. `os g agent` now says so and points at skills — the +third-party extension primitive — which are authored as `src/skills/.skill.ts` +with `defineSkill`. There is no `os g skill` scaffolder yet. ### Cloud — publish & install diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 661c5c82a8..573e891744 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -4,7 +4,7 @@ import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; import fs from 'fs'; import path from 'path'; -import { printHeader, printSuccess, printError, printInfo, printStep, createTimer } from '../utils/format.js'; +import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, CLI_ALIAS } from '../utils/format.js'; // ─── Metadata Type Templates ──────────────────────────────────────── @@ -125,30 +125,6 @@ export default ${toCamelCase(name)}Flow; `, }, - agent: { - description: 'AI agent', - defaultDir: 'src/agents', - generate: (name: string) => `import * as AI from '@objectstack/spec/ai'; - -/** - * ${toTitleCase(name)} Agent - */ -const ${toCamelCase(name)}Agent: AI.Agent = { - name: '${toSnakeCase(name)}_agent', - label: '${toTitleCase(name)} Agent', - role: '${toTitleCase(name)} assistant', - instructions: 'You are a helpful ${toTitleCase(name).toLowerCase()} assistant.', - model: { - provider: 'openai', - model: 'gpt-4o', - }, - tools: [], -}; - -export default ${toCamelCase(name)}Agent; -`, - }, - dashboard: { description: 'Analytics dashboard', defaultDir: 'src/dashboards', @@ -189,6 +165,58 @@ export default ${toCamelCase(name)}App; }, }; +// ─── Retired Generators ───────────────────────────────────────────── + +/** + * Scaffolder types that were withdrawn, and what this command says when one + * of them is run. + * + * A retired type is NOT an unknown type, and deliberately does not fall + * through to the `Unknown type:` branch in {@link runMetadataGeneration}. + * That branch prints the surviving roster and nothing else, so an author + * arriving from a doc page, a tutorial or a CI script that still names the + * retired type would learn only that their spelling is not on the list — + * and the natural next move is to hunt for the right spelling of something + * that no longer exists. + * + * `agent` (ADR-0063 §2, which reversed ADR-0040 §3): the kernel ships exactly + * two agents, `ask` and `build`, bound by surface and never picked from a + * roster. Tenant / app-package agents were withdrawn, and the runtime catalog + * filters out every non-platform agent record. The file this generator wrote + * into `src/agents/` therefore passed `os validate`, published without + * complaint, and was then dropped on the floor: no error at any step, the + * agent simply never appeared. Retiring the command silently would have moved + * that silence one step earlier instead of ending it, which is why each entry + * owes both halves — the decision that withdrew the surface, and the surface + * to author instead. + */ +const RETIRED_GENERATORS: Record` was retired — …". */ + reason: string; + /** Body lines, printed in order; an empty string prints a blank line. */ + detail: string[]; +}> = { + agent: { + reason: 'agents are platform-internal (ADR-0063 §2).', + detail: [ + 'The kernel ships exactly two agents, `ask` and `build`, bound by surface.', + 'An agent you author still parses and still publishes — and the runtime', + 'catalog then filters it out, so it never appears and nothing tells you.', + 'This command scaffolded exactly that file, so it is retired, not repaired.', + '', + 'Author a SKILL instead. Skills (plus tools / MCP) are the third-party', + 'extension primitive ADR-0063 names — the live surface this one was not.', + '', + 'There is no `os g skill` scaffolder yet. Write the file by hand:', + '', + ' src/skills/.skill.ts', + " import { defineSkill } from '@objectstack/spec/ai';", + '', + 'Docs: https://objectstack.ai/docs/ai/agents', + ], + }, +}; + // ─── Helpers ──────────────────────────────────────────────────────── function toCamelCase(str: string): string { @@ -307,6 +335,19 @@ function generateTypesFromConfig(config: Record): string { async function runMetadataGeneration(type: string, name: string, flags: { dir?: string; dryRun?: boolean }): Promise { printHeader('Generate'); + // A withdrawn type answers for itself, ahead of the roster lookup — see + // RETIRED_GENERATORS for why "unknown type" is the wrong answer here. + const retired = RETIRED_GENERATORS[type]; + if (retired) { + printError(`\`${CLI_ALIAS} g ${type}\` was retired — ${retired.reason}`); + console.log(''); + for (const line of retired.detail) { + console.log(line ? chalk.dim(` ${line}`) : ''); + } + console.log(''); + process.exit(1); + } + const generator = GENERATORS[type]; if (!generator) { printError(`Unknown type: ${type}`); @@ -874,7 +915,7 @@ export default class Generate extends Command { static override aliases = ['g']; static override args = { - type: Args.string({ description: 'Metadata type to generate (object, view, action, flow, agent, dashboard, app)', required: true }), + type: Args.string({ description: 'Metadata type to generate (object, view, action, flow, dashboard, app)', required: true }), name: Args.string({ description: 'Name for the metadata (use kebab-case)', required: false }), }; diff --git a/packages/cli/test/generate-agent-retired.e2e.test.ts b/packages/cli/test/generate-agent-retired.e2e.test.ts new file mode 100644 index 0000000000..0186ad7f37 --- /dev/null +++ b/packages/cli/test/generate-agent-retired.e2e.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN (#10359) — `os g agent` is gone, and its refusal names the replacement. + * + * The generator scaffolded into `src/agents`, a surface ADR-0063 §2 withdrew: + * the kernel ships exactly two agents (`ask`, `build`) and the runtime catalog + * filters out every other agent record. So the file it wrote passed + * `os validate`, published without complaint, and then never appeared — no + * error at any step. That is the silent-strip failure mode, arriving through + * the scaffolder. + * + * Deleting the entry alone would have moved the silence one step earlier + * rather than ending it: `Unknown type: agent` followed by the surviving + * roster tells the author their spelling is not on the list, and the natural + * next move is to hunt for the right spelling of something that no longer + * exists. So the assertions below are about the CONTENT of the refusal, not + * only about its absence from the roster — a bare "unknown generator" passes + * every "the type is gone" assertion and fails this file. + * + * Assertions are on a REAL CHILD PROCESS and on stdout, for the two reasons + * `invocation-loudness.e2e.test.ts` documents at length: `process.exitCode` + * inside a vitest worker is not an exit status (a CI script judges this + * command by `$?`), and these commands print through `utils/format.ts`, whose + * `printError` writes to stdout. Spawned through `bin/run-dev.js` + tsx so the + * suite does not depend on `packages/cli/dist` having been built. + * + * ⛔ Not asserted, because it is out of scope and stays that way: that + * `os g skill` exists. It does not — the skill scaffolder is the split-out + * follow-up half, and the message says so rather than promising it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** oclif + tsx cold start, with every command module loaded; ~2-10 s when healthy. */ +const RUN_TIMEOUT_MS = 180_000; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runTsx(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + args, + { cwd, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, NO_COLOR: '1' } }, + (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; null/undefined means the child + // was signalled — a different failure, never reported as 0. + code: err + ? typeof (err as { code?: unknown }).code === 'number' + ? (err as unknown as { code: number }).code + : 1 + : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +let dir: string; +let retired: Run; +let unknown: Run; +let survivor: Run; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-g-agent-retired-')); + + // Sequential on purpose: three cold tsx starts, each loading every command + // module, in a container several agents share. + retired = await runTsx([CLI, 'g', 'agent', 'support'], dir); + unknown = await runTsx([CLI, 'g', 'nonexistent-type', 'support'], dir); + survivor = await runTsx([CLI, 'g', 'object', 'customer', '--dry-run'], dir); +}, RUN_TIMEOUT_MS); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('[#10359] `os g agent` is retired', () => { + it('fails instead of scaffolding — a CI script that still calls it stops', () => { + expect(retired.code).toBe(1); + }); + + it('says the command was RETIRED, not that the type is unrecognised', () => { + expect(retired.stdout).toContain('was retired'); + // The generic branch would have swallowed the whole explanation. + expect(retired.stdout).not.toContain('Unknown type:'); + }); + + it('names the decision that withdrew the surface', () => { + expect(retired.stdout).toContain('ADR-0063'); + expect(retired.stdout).toContain('platform-internal'); + }); + + it('names the two platform agents and the silent strip, so the WHY is in the message', () => { + expect(retired.stdout).toContain('`ask`'); + expect(retired.stdout).toContain('`build`'); + expect(retired.stdout).toContain('never appears'); + }); + + it('points the author at SKILLS — the half a bare removal would drop', () => { + expect(retired.stdout).toContain('SKILL'); + expect(retired.stdout).toContain('src/skills/'); + expect(retired.stdout).toContain('defineSkill'); + }); + + it('writes nothing — no `src/agents/`, no barrel index', () => { + expect(existsSync(join(dir, 'src', 'agents'))).toBe(false); + expect(existsSync(join(dir, 'src'))).toBe(false); + }); +}); + +describe('[#10359] the roster no longer advertises `agent`', () => { + it('omits `agent` from the available types an unknown spelling prints', () => { + expect(unknown.code).toBe(1); + expect(unknown.stdout).toContain('Unknown type:'); + expect(unknown.stdout).toContain('Available types:'); + expect(unknown.stdout).not.toMatch(/^\s*agent\s/m); + }); + + it('still lists the six that survive', () => { + for (const type of ['object', 'view', 'action', 'flow', 'dashboard', 'app']) { + expect(unknown.stdout).toContain(type); + } + }); +}); + +describe('[#10359] the generators that were not retired still work', () => { + it('`os g object … --dry-run` still previews a typed object file', () => { + expect(survivor.code).toBe(0); + expect(survivor.stdout).toContain('Dry run'); + expect(survivor.stdout).toContain("import * as Data from '@objectstack/spec/data'"); + }); +}); diff --git a/scripts/adr-anchors/packages__cli__src__commands__generate.ts.json b/scripts/adr-anchors/packages__cli__src__commands__generate.ts.json new file mode 100644 index 0000000000..d340defecf --- /dev/null +++ b/scripts/adr-anchors/packages__cli__src__commands__generate.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/cli/src/commands/generate.ts", + "adrs": [ + "ADR-0063" + ], + "invariant": "There is no `agent` scaffolder, and the absence is a decision, not an oversight. ADR-0063 §2 closed `*.agent.ts` to third parties — the kernel ships exactly two agents (`ask`, `build`) and the runtime catalog filters out every other agent record — so a scaffolded `src/agents/*.ts` validated, published, and then vanished with no error at any step. Do not re-add an `agent` entry to `GENERATORS`. `agent` lives in `RETIRED_GENERATORS` instead, and that branch owes both halves of the message: the decision that withdrew the surface, and skills (`src/skills/.skill.ts`, `defineSkill`) as the surface to author instead. Falling back to the generic `Unknown type:` roster drops the pointer and leaves the author as stranded as the silent strip did, one step earlier." +}