diff --git a/.changeset/ai-agent-authoring-and-tools-removal.md b/.changeset/ai-agent-authoring-and-tools-removal.md new file mode 100644 index 0000000000..e9b4052b7b --- /dev/null +++ b/.changeset/ai-agent-authoring-and-tools-removal.md @@ -0,0 +1,43 @@ +--- +"@objectstack/spec": major +"@objectstack/lint": minor +--- + +feat(spec,lint)!: remove `agent.tools[]`, lint agent authoring, and resolve `action_` only when it actually materialises (#3820, ADR-0109 accepted) + +**Breaking — `agent.tools[]` is removed.** ADR-0064's central invariant is +"an agent's tool set is the union of its surface-compatible skills' tools; +nothing falls through to the global registry", and this legacy inline slot +was the one seam that broke it: the runtime resolved `agent.tools[].name` +against the **full** tool registry with no surface check, so an `ask`-surface +agent could name an authoring tool and get it. Removing the field makes the +invariant structural — there is no second slot to disagree with the skills — +rather than a rule every reader has to remember (ADR-0049 "design+enforce or +remove"). `AIToolSchema` / the `AITool` type go with it. + +*Migration:* attach capability through `skills`. An agent authoring `tools` is +not a parse error — Zod strips the unknown key — so existing stacks keep +parsing, but the slot no longer does anything. + +**`validate-ai-tool-references` now models AI exposure.** The rule previously +resolved `action_` against every declared action. The runtime is far +stricter (ADR-0011): it materialises a tool only when the action opts in with +`ai.exposed: true` + `ai.description` **and** has a headless path (type +`script`/`api`/`flow` with a target or body — `url`/`modal`/`form` are +UI-only). Resolving against all actions therefore blessed references the agent +could never call — the exact failure the rule exists to catch. Unresolved +`action_*` references now get their own message and fix, since "the action +isn't exposed" and "the name is fictional" need different answers. + +**New rule `validate-ai-agent-authoring`** (`agent-authoring-withdrawn`, +warning): flags a stack that declares `stack.agents`. Tenant/app-package +agents were withdrawn in ADR-0063 §2 — the runtime filters them from the +catalog and refuses to load them — but `defineStack` still accepted the array, +so an app could ship agents that parse, validate, and never run. This is the +authoring-time signal that was missing (ADR-0078: loud at the producer, +tolerant at the consumer). Joins `REFERENCE_INTEGRITY_RULES`. + +ADR-0109 is now **Accepted — implemented (Phase 1)**, and the AI docs teach +the zero-tool-record default path, including the three conditions that decide +whether `action_` exists and why a `modal` action staying human-driven +is a design answer rather than a gap. diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx index e17a164cd0..5124fee4ae 100644 --- a/content/docs/ai/agents.mdx +++ b/content/docs/ai/agents.mdx @@ -6,8 +6,9 @@ description: The two platform agents (ask and build), how skills extend them, an # AI Agents Part of the [AI module](/docs/ai). In the **open edition**, agents, tools, and -skills are **typed metadata**: you author them as source with `defineAgent` / -`defineSkill` / `defineTool` from the open `@objectstack/spec/ai` package, and an +skills are **typed metadata**. You author **skills** (`defineSkill`) — agents are +platform-owned and tool records are optional (see below) — from the open +`@objectstack/spec/ai` package, and an external AI client (Claude, Cursor, a local model — any MCP client) reaches your objects, queries, and business **Actions** through `@objectstack/mcp` (BYO-AI), all governed by RLS. This page describes that metadata and the `AgentSchema` it @@ -112,12 +113,74 @@ assistant to *write* your metadata and never run. Same word, two layers. `*.agent.ts` is **closed to third parties** — the `agent` metadata type is `allowRuntimeCreate:false, allowOrgOverride:false`, reserved for the two platform agents and platform-owned subagents (ADR-0063 §2). To give the `ask` agent a new -capability you author an agent **skill** (`*.skill.ts`) whose `tools` reference -your Actions / Flows / queries; it then attaches to `ask`. Every skill declares +capability you author an agent **skill** (`*.skill.ts`). Every skill declares `surface: 'ask' | 'build' | 'both'`, and an agent's tool set is the **union of its surface-compatible skills' tools** — there is no global fall-through, so a skill reaches an agent only when their surfaces match ([ADR-0064](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0064-tool-scoping-to-agent.md)). +`os validate` warns if a stack declares an agent (`agent-authoring-withdrawn`). + +### A skill needs **no tool records** — name the Action + +Per [ADR-0109](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0109-ai-tool-authoring-model.md) +the default path declares **zero** `defineTool` records. The runtime already +materialises one `action_` tool per AI-exposed Action, so a skill names +that tool directly. The Action is the same one your UI button runs, so +permissions, validation and audit are identical whether a human clicks or the +assistant calls: + +```typescript +import { defineAction } from '@objectstack/spec/ui'; +import { defineSkill } from '@objectstack/spec/ai'; + +// 1. The Action you already have — opt it in to AI. +export const ConvertLeadAction = defineAction({ + name: 'convert_lead', + label: 'Convert Lead', + objectName: 'crm_lead', + type: 'flow', // headless: script | api | flow + target: 'lead_conversion_flow', + ai: { + exposed: true, // ADR-0011 — opt-in, default off + description: 'Converts a qualified lead into an account, contact and opportunity.', + }, +}); + +// 2. The skill names its materialised tool. No defineTool anywhere. +export const LeadQualificationSkill = defineSkill({ + name: 'lead_qualification', + label: 'Lead Qualification', + surface: 'ask', + instructions: `Score the lead with BANT from what you read, then convert it +when the user agrees. Scoring is your judgement — there is no scoring tool.`, + tools: ['get_record', 'query_records', 'action_convert_lead'], +}); +``` + +Three things decide whether `action_` exists, and `os validate` checks +all three (`ai-skill-tool-unresolved`): + +| Requirement | Why | +|---|---| +| `ai.exposed: true` + `ai.description` (≥40 chars) | ADR-0011 governance gate — AI reach is opt-in, and the description is the LLM's contract | +| type is `script` / `api` / `flow` | `url` / `modal` / `form` are UI-only: they have no headless path, so they stay human-driven **by design** | +| a `target` (or a `body`, for `script`) | something has to dispatch | + +A `modal` Action is not a gap to fix. When the operation should collect input +from a person — an escalation reason, a refund amount — leave it modal and have +the skill's instructions *recommend* the button instead of calling it. + +The other tools a skill may name are the **platform** ones the runtime +registers (`describe_object`, `query_records`, `aggregate_data`, +`search_knowledge`, `visualize_data`, …). Between those and your Actions, most +skills need nothing else: "analyse the pipeline", "draft this email" and "score +this lead" are *reasoning* the model does with data — writing them as tool +names is the most common authoring mistake, and produces an assistant that +claims abilities it does not have. + +A `defineTool` record is an **optional refinement layer**, not a required step +— reach for one only when the AI-facing surface must differ from the Action +itself (a different LLM-facing description, fewer exposed parameters). Both `surface:'ask'` and `surface:'build'` skills run only where the in-UI AI runtime exists — **ObjectOS**. On the open framework there is no in-product agent to attach them to; author capability @@ -193,12 +256,9 @@ Always be professional and data-driven.`, maxTokens: 2000, }, - // References to Actions/Flows exposed as tools (see "Actions as Tools"). - tools: [ - { type: 'flow', name: 'analyze_lead', description: 'Analyze a lead and provide a qualification score' }, - { type: 'flow', name: 'suggest_next_action', description: 'Suggest the next best action for an opportunity' }, - { type: 'action', name: 'generate_email', description: 'Generate a personalized email template' }, - ], + // Capability comes from skills — the only tool-bearing slot (ADR-0064). + // Each skill names the platform tools and `action_` tools it needs. + skills: ['lead_qualification', 'opportunity_coaching', 'email_drafting'], // RAG access: sources to recruit knowledge from + vector store indexes. knowledge: { @@ -233,11 +293,10 @@ Always be empathetic and solution-focused.`, maxTokens: 1500, }, - // `search_knowledge` is provided by the Knowledge Protocol tool, not declared inline. - tools: [ - { type: 'flow', name: 'triage_case', description: 'Analyze a case and assign priority' }, - { type: 'action', name: 'generate_response', description: 'Generate a customer response' }, - ], + // Skills carry the tools. `case_triage` names the platform data tools it + // reads with; `knowledge_search` names `search_knowledge` (Knowledge + // Protocol). Neither needs a `defineTool` record. + skills: ['case_triage', 'knowledge_search', 'response_drafting'], knowledge: { sources: ['support-kb', 'cases'], @@ -271,10 +330,10 @@ Use reputable data sources.`, maxTokens: 1000, }, - tools: [ - { type: 'flow', name: 'lookup_company', description: 'Look up company information' }, - { type: 'flow', name: 'enrich_contact', description: 'Enrich contact information' }, - ], + // The enrichment Flows are exposed with `ai.exposed`, so the runtime + // materialises `action_lookup_company` / `action_enrich_contact` for the + // skill to name. + skills: ['contact_enrichment'], }); ``` @@ -307,11 +366,10 @@ Use the data tools to query records and aggregate metrics.`, maxTokens: 3000, }, - // Built-in data tools (query_records / get_record / aggregate_data) are - // available to agents automatically once registered — see "Natural Language Queries". - tools: [ - { type: 'flow', name: 'analyze_pipeline', description: 'Analyze sales pipeline health' }, - ], + // Pipeline analysis is reasoning over the platform's own data tools + // (query_records / aggregate_data / visualize_data), which the skill + // names directly — see "Natural Language Queries". + skills: ['revenue_forecasting'], knowledge: { sources: ['opportunities', 'pipeline'], diff --git a/content/docs/getting-started/common-patterns.mdx b/content/docs/getting-started/common-patterns.mdx index 896185952b..bed2f6fc9a 100644 --- a/content/docs/getting-started/common-patterns.mdx +++ b/content/docs/getting-started/common-patterns.mdx @@ -397,23 +397,10 @@ export const supportAgent = defineAgent({ instructions: `You are a helpful customer support agent. You can search for customer records, look up order status, and create support tickets. Always be polite and professional.`, - tools: [ - { - type: 'query', - name: 'contact', - description: 'Search customer records' - }, - { - type: 'query', - name: 'order', - description: 'Look up order status' - }, - { - type: 'action', - name: 'create_support_ticket', - description: 'Create a support ticket' - } - ] + // Capability lives in skills, never inline on the agent (ADR-0064): a + // skill names the platform data tools plus the `action_` tools + // materialised from your own AI-exposed Actions. + skills: ['customer_lookup', 'order_status', 'ticket_intake'] }); ``` diff --git a/content/docs/references/ai/agent.mdx b/content/docs/references/ai/agent.mdx index b3c9e414bc..d3b8af48ca 100644 --- a/content/docs/references/ai/agent.mdx +++ b/content/docs/references/ai/agent.mdx @@ -14,8 +14,8 @@ AI Model Configuration ## TypeScript Usage ```typescript -import { AIKnowledge, AIModelConfig, AITool, Agent, StructuredOutputConfig, StructuredOutputFormat, TransformPipelineStep } from '@objectstack/spec/ai'; -import type { AIKnowledge, AIModelConfig, AITool, Agent, StructuredOutputConfig, StructuredOutputFormat, TransformPipelineStep } from '@objectstack/spec/ai'; +import { AIKnowledge, AIModelConfig, Agent, StructuredOutputConfig, StructuredOutputFormat, TransformPipelineStep } from '@objectstack/spec/ai'; +import type { AIKnowledge, AIModelConfig, Agent, StructuredOutputConfig, StructuredOutputFormat, TransformPipelineStep } from '@objectstack/spec/ai'; // Validate data const result = AIKnowledge.parse(data); @@ -49,19 +49,6 @@ const result = AIKnowledge.parse(data); | **topP** | `number` | optional | | ---- - -## AITool - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'action' \| 'flow' \| 'query' \| 'vector_search'>` | ✅ | | -| **name** | `string` | ✅ | Reference name (Action Name, Flow Name) | -| **description** | `string` | optional | Override description for the LLM | - - --- ## Agent @@ -79,7 +66,6 @@ const result = AIKnowledge.parse(data); | **lifecycle** | `{ id: string; description?: string; contextSchema?: Record; initial: string; … }` | optional | [EXPERIMENTAL — not enforced] State machine defining the agent conversation flow and constraints. Parsed but no runtime consumer yet (liveness #1878/#1893). | | **surface** | `Enum<'ask' \| 'build'>` | ✅ | Product surface this agent binds ('ask' \| 'build') — ADR-0063 §1 | | **skills** | `string[]` | optional | Skill names to attach (Agent→Skill→Tool architecture) | -| **tools** | `{ type: Enum<'action' \| 'flow' \| 'query' \| 'vector_search'>; name: string; description?: string }[]` | optional | Direct tool references (legacy fallback) | | **knowledge** | `{ sources?: string[]; topics?: any; indexes: string[] }` | optional | RAG access | | **active** | `boolean` | ✅ | | | **access** | `string[]` | optional | Who can chat with this agent | diff --git a/docs/adr/0109-ai-tool-authoring-model.md b/docs/adr/0109-ai-tool-authoring-model.md index c8881f9151..cccc207a19 100644 --- a/docs/adr/0109-ai-tool-authoring-model.md +++ b/docs/adr/0109-ai-tool-authoring-model.md @@ -1,6 +1,7 @@ # ADR-0109: The AI tool authoring model — the default third-party path needs no tool records -**Status**: Proposed (2026-07-28; revised same day — see Revision note). Phase 1 (platform tool-name registry + advisory reference lint) is implemented alongside this revision; Phase 2 (the optional refinement layer) awaits acceptance. +**Status**: **Accepted — implemented (Phase 1)** (2026-07-28; revised same day before acceptance — see Revision note). +Evidence: `packages/spec/src/system/constants/platform-tool-names.ts` (registry + `platform-tool-names.test.ts`); `packages/lint/src/validate-ai-tool-references.ts` (+ `.test.ts`, wired into `REFERENCE_INTEGRITY_RULES`); conformance in `../cloud` `service-ai`/`service-ai-studio` (`platform-tool-names-conformance.test.ts`, cloud#908). First app on the model: `../hotcrm` skills — 22 tool references, 0 findings, down from 16 references with 10 dead (hotcrm#512). Phase 2 (the optional refinement layer) remains open and is gated on a real refinement need. **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0063](./0063-two-kernel-agents-skills-are-the-extension-primitive.md) (skills + tools are the third-party extension primitive), [ADR-0064](./0064-tool-scoping-to-agent.md) (an agent's tools are its skills' tools), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove) **Consumers**: `@objectstack/spec` (`system/constants/platform-tool-names.ts`, `ai/tool.zod.ts`, `stack.zod.ts`), `@objectstack/lint` (`validate-ai-tool-references`), `../cloud/service-ai` + `service-ai-studio` (registry conformance) @@ -130,5 +131,7 @@ lint could not see: no reference rule could be correct in either direction - [x] `PLATFORM_PROVIDED_TOOL_NAMES` + `PLATFORM_TOOL_FAMILY_PREFIXES` + invariant tests (spec — this change). - [x] `validate-ai-tool-references`, advisory, in `REFERENCE_INTEGRITY_RULES` (lint — this change). - [x] `'tools'` into `composeStacks`' concat list (spec — this change). -- [ ] Registry conformance tests in `../cloud` `service-ai` / `service-ai-studio` (cloud PR; activates fully when the cloud `.objectstack-sha` pin advances past this change). +- [x] Registry conformance tests in `../cloud` `service-ai` / `service-ai-studio` (cloud#908; feature-detected, activates when the `.objectstack-sha` pin advances past Phase 1). +- [x] The lint rule resolves `action_` only for actions that ACTUALLY materialise — `ai.exposed` + `ai.description` + a headless type (ADR-0011). Resolving against every declared action blessed references the agent could never call; caught while porting HotCRM, where 3 of 5 referenced actions are `type:'modal'` (UI-only). +- [x] First app ported: `../hotcrm` (hotcrm#512) — 10 fictional tools removed, the two withdrawn agents deleted, state changes routed through `action_`. - [ ] **Phase 2 (on acceptance + a real refinement need):** `binding` on `ToolSchema`; flow exposure; boot-mirror provenance guard; revisit `tool` metadata-type flags; ratchet the lint rule to error per ADR-0078 once the registry has soaked a release. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index ef745dfc02..1d0685ec8c 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -240,6 +240,15 @@ export type { AiToolRefSeverity, } from './validate-ai-tool-references.js'; +export { + validateAiAgentAuthoring, + AGENT_AUTHORING_WITHDRAWN, +} from './validate-ai-agent-authoring.js'; +export type { + AiAgentAuthoringFinding, + AiAgentAuthoringSeverity, +} from './validate-ai-agent-authoring.js'; + // One entry point for the reference-resolution rules above (#3583 §5 D5). // Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint` // and `compile` at once — the CLI call sites do not change. diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index e4d0883b42..8939c703a5 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -24,6 +24,7 @@ describe('reference-integrity suite — membership', () => { 'validateFlowTemplatePaths', 'validateAiSurfaceAffinity', 'validateAiToolReferences', + 'validateAiAgentAuthoring', ]); }); @@ -111,6 +112,8 @@ describe('reference-integrity suite — every member actually runs', () => { ], // validateAiSurfaceAffinity: an 'ask' agent binding a 'build' skill — the // runtime throws on this at chat time (ADR-0064 §3). + // validateAiAgentAuthoring: declaring an agent at all is withdrawn + // (ADR-0063 §2) — one fixture, two rules. agents: [{ name: 'helper', surface: 'ask', skills: ['metadata_authoring'] }], // validateAiToolReferences: a tool name nothing declares, registers, or // materialises (the HotCRM fictional-tool class). @@ -149,6 +152,7 @@ describe('reference-integrity suite — every member actually runs', () => { expect(rules).toContain('flow-template-unknown-field'); expect(rules).toContain('ai-skill-surface-mismatch'); expect(rules).toContain('ai-skill-tool-unresolved'); + expect(rules).toContain('agent-authoring-withdrawn'); }); it('carries a gating flow-template finding through the suite (#3810)', () => { @@ -170,9 +174,9 @@ describe('reference-integrity suite — every member actually runs', () => { expect(typeof f.message).toBe('string'); expect(typeof f.hint).toBe('string'); } - // Object references run first, AI tool references last. + // Object references run first, agent-authoring last. expect(findings[0].rule).toBe('object-reference-unknown'); - expect(findings[findings.length - 1].rule).toBe('ai-skill-tool-unresolved'); + expect(findings[findings.length - 1].rule).toBe('agent-authoring-withdrawn'); }); it('returns nothing for an empty stack', () => { diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index ce435fad43..79840b9aad 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -54,6 +54,7 @@ import { validateTranslationReferences } from './validate-translation-references import { validateFlowTemplatePaths } from './validate-flow-template-paths.js'; import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js'; import { validateAiToolReferences } from './validate-ai-tool-references.js'; +import { validateAiAgentAuthoring } from './validate-ai-agent-authoring.js'; export type ReferenceIntegritySeverity = 'error' | 'warning'; @@ -98,6 +99,7 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ { name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths }, { name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity }, { name: 'validateAiToolReferences', run: validateAiToolReferences }, + { name: 'validateAiAgentAuthoring', run: validateAiAgentAuthoring }, ]; /** diff --git a/packages/lint/src/validate-ai-agent-authoring.test.ts b/packages/lint/src/validate-ai-agent-authoring.test.ts new file mode 100644 index 0000000000..02c4ca491c --- /dev/null +++ b/packages/lint/src/validate-ai-agent-authoring.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateAiAgentAuthoring, + AGENT_AUTHORING_WITHDRAWN, +} from './validate-ai-agent-authoring.js'; + +describe('validate-ai-agent-authoring', () => { + it('is silent for the stack every app package should be — no agents at all', () => { + expect(validateAiAgentAuthoring({ skills: [{ name: 's', tools: [] }] })).toEqual([]); + expect(validateAiAgentAuthoring({ agents: [] })).toEqual([]); + expect(validateAiAgentAuthoring({})).toEqual([]); + }); + + it('flags a custom agent and points at the skills that already carry it', () => { + // The HotCRM shape: a persona referencing skills that do the actual work. + const stack = { + agents: [ + { + name: 'sales_copilot', + label: 'Sales Copilot', + skills: ['live_data', 'lead_qualification', 'email_drafting'], + }, + ], + }; + const findings = validateAiAgentAuthoring(stack); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: AGENT_AUTHORING_WITHDRAWN, + where: 'agent "sales_copilot"', + path: 'agents[0]', + }); + expect(findings[0].message).toContain('ADR-0063 §2'); + expect(findings[0].hint).toContain('3 skills'); + }); + + it('omits the skill sentence when the agent references none', () => { + const findings = validateAiAgentAuthoring({ agents: [{ name: 'lonely' }] }); + expect(findings).toHaveLength(1); + expect(findings[0].hint).not.toContain('skills this agent references'); + }); + + it('uses distinct wording when a stack shadows a platform agent id', () => { + for (const name of ['ask', 'build', 'data_chat', 'metadata_assistant']) { + const findings = validateAiAgentAuthoring({ agents: [{ name }] }); + expect(findings, name).toHaveLength(1); + expect(findings[0].message).toContain('PLATFORM agent id'); + expect(findings[0].hint).toContain('the platform owns'); + } + }); + + it('reports every declared agent with stable paths', () => { + const stack = { agents: [{ name: 'a' }, { name: 'b' }, { name: 'c' }] }; + expect(validateAiAgentAuthoring(stack).map((f) => f.path)).toEqual([ + 'agents[0]', + 'agents[1]', + 'agents[2]', + ]); + }); + + it('tolerates junk shapes without throwing', () => { + expect(validateAiAgentAuthoring({ agents: 'nope' } as never)).toEqual([]); + expect(validateAiAgentAuthoring({ agents: [null, 7] } as never)).toEqual([]); + expect(validateAiAgentAuthoring({ agents: [{ skills: 'nope' }] } as never)).toHaveLength(1); + }); +}); diff --git a/packages/lint/src/validate-ai-agent-authoring.ts b/packages/lint/src/validate-ai-agent-authoring.ts new file mode 100644 index 0000000000..3d65fa7bf0 --- /dev/null +++ b/packages/lint/src/validate-ai-agent-authoring.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0063 §2] `stack.agents` is a platform-internal slot (issue #3820). + * + * ADR-0063 §2 withdrew tenant/app-package custom agents: the kernel ships + * exactly two agents (`ask`, `build`), the surface the user is in binds one, + * and third parties extend the platform by authoring **skills**, never + * `*.agent.ts`. The `agent` metadata type carries the decision + * (`allowRuntimeCreate: false, allowOrgOverride: false`), and the runtime + * enforces it on both paths — `listAgents()` filters non-platform records out + * of the catalog, and `loadAgent()` refuses them outright (cloud#904), so a + * stack-authored agent 404s on chat and cannot be pinned via + * `app.defaultAgent`. + * + * What was missing is the AUTHORING-time signal. `defineStack` still accepts + * an `agents` array, so an app package could declare agents that parse, + * validate, and build into the artifact — and then do nothing at runtime. + * HotCRM shipped two of them for months. That is the ADR-0078 shape this rule + * closes: loud at the producer, tolerant at the consumer (Prime Directive + * #12). + * + * Severity is **warning**, not error, for one reason: the platform's own + * packages legitimately author agent records, and this rule cannot tell a + * platform package from an app package by reading the stack alone. A warning + * that names the runtime consequence is honest for both readers; the runtime + * is what actually gates. Deliberately NOT a Zod refine — an existing stack + * must keep parsing (ADR-0078 non-goal #1). + */ + +export const AGENT_AUTHORING_WITHDRAWN = 'agent-authoring-withdrawn'; + +export type AiAgentAuthoringSeverity = 'error' | 'warning'; + +export interface AiAgentAuthoringFinding { + /** Always `warning` — the runtime is the gate; this is the authoring-time signal. */ + severity: AiAgentAuthoringSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `agent "sales_copilot"`. */ + where: string; + /** Config path, e.g. `agents[0]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v.filter((x): x is AnyRec => !!x && typeof x === 'object'); + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * The two platform agent ids. A stack that re-declares one of these is doing + * something different from inventing a custom persona (it is shadowing a + * platform record), so it gets its own wording. + */ +const PLATFORM_AGENT_NAMES = new Set(['ask', 'build', 'data_chat', 'metadata_assistant']); + +/** + * Flag every agent declared in a stack. Returns findings (empty = clean, + * which is what every app package should be). + */ +export function validateAiAgentAuthoring(stack: AnyRec): AiAgentAuthoringFinding[] { + const findings: AiAgentAuthoringFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + const agents = asArray(stack.agents); + for (let ai = 0; ai < agents.length; ai++) { + const agent = agents[ai]; + const name = strName(agent.name) ?? `#${ai}`; + const isPlatformName = PLATFORM_AGENT_NAMES.has(name); + const skillCount = Array.isArray(agent.skills) ? agent.skills.length : 0; + + findings.push({ + severity: 'warning', + rule: AGENT_AUTHORING_WITHDRAWN, + where: `agent "${name}"`, + path: `agents[${ai}]`, + message: isPlatformName + ? `This stack declares an agent named "${name}", which is a PLATFORM agent id. The ` + + `runtime serves its own record for that name and ignores this one — the declaration ` + + `has no effect and will drift from the platform's definition.` + : `This stack declares the agent "${name}", but tenant/app-package agents were withdrawn ` + + `(ADR-0063 §2): the kernel ships exactly two agents (\`ask\`, \`build\`) and the surface ` + + `the user is in binds one. The runtime filters this record out of the agent catalog and ` + + `refuses to load it, so it never runs — it parses, validates, and ships as inert ` + + `metadata.`, + hint: isPlatformName + ? `Remove the declaration; the platform owns "${name}". Extend it with skills instead.` + : `Delete the agent and express its capability as skills. Everything an agent carried ` + + `that a skill does not is persona text: move the useful parts of \`instructions\` into ` + + `the skills' own instructions.` + + (skillCount > 0 + ? ` The ${skillCount} skill${skillCount === 1 ? '' : 's'} this agent references ` + + `already carry the capability — they attach to the platform agent by \`surface\` ` + + `affinity, so nothing is lost by dropping the persona.` + : ``), + }); + } + + return findings; +} diff --git a/packages/lint/src/validate-ai-tool-references.test.ts b/packages/lint/src/validate-ai-tool-references.test.ts index afecf15c42..25205c93fd 100644 --- a/packages/lint/src/validate-ai-tool-references.test.ts +++ b/packages/lint/src/validate-ai-tool-references.test.ts @@ -50,20 +50,73 @@ describe('validate-ai-tool-references', () => { expect(validateAiToolReferences(stack)).toEqual([]); }); + const exposed = (name: string, type = 'script') => ({ + name, + label: name, + type, + target: name, + ai: { exposed: true, description: 'A sufficiently long LLM-facing description of the action.' }, + }); + it('resolves materialised action tools from stack-level and object-level actions', () => { const stack = { - actions: [{ name: 'send_invoice', label: 'Send' }], - objects: [{ name: 'crm_case', actions: [{ name: 'triage_case', label: 'Triage' }] }], + actions: [exposed('send_invoice')], + objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }], skills: [{ name: 's', tools: ['action_send_invoice', 'action_triage_case'] }], }; expect(validateAiToolReferences(stack)).toEqual([]); }); + // ADR-0011 — the runtime materialises `action_` ONLY for an action + // that opted in AND has a headless path. Resolving against every declared + // action would bless a reference the agent can never call: the exact + // failure this rule exists to catch, one layer down. + it('does NOT resolve an action that is not AI-exposed', () => { + const stack = { + actions: [{ name: 'send_invoice', label: 'Send', type: 'script', target: 'send_invoice' }], + skills: [{ name: 's', tools: ['action_send_invoice'] }], + }; + const findings = validateAiToolReferences(stack); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('does not become an AI tool'); + expect(findings[0].hint).toContain('ai: { exposed: true'); + }); + + it('does NOT resolve a UI-only action type, even when opted in', () => { + // `modal`/`form`/`url` have no headless invocation path — staying + // human-driven is a legitimate design answer, and the hint says so. + for (const type of ['modal', 'form', 'url']) { + const stack = { + actions: [{ ...exposed('escalate_case'), type }], + skills: [{ name: 's', tools: ['action_escalate_case'] }], + }; + const findings = validateAiToolReferences(stack); + expect(findings, type).toHaveLength(1); + expect(findings[0].hint).toContain('stays human-driven by design'); + } + }); + + it('does NOT resolve an exposed action missing its LLM-facing description', () => { + const stack = { + actions: [{ name: 'send_invoice', type: 'script', target: 'x', ai: { exposed: true } }], + skills: [{ name: 's', tools: ['action_send_invoice'] }], + }; + expect(validateAiToolReferences(stack)).toHaveLength(1); + }); + + it('does NOT resolve a flow/api action with no target to dispatch', () => { + const stack = { + actions: [{ name: 'run_it', type: 'flow', ai: { exposed: true, description: 'x'.repeat(45) } }], + skills: [{ name: 's', tools: ['action_run_it'] }], + }; + expect(validateAiToolReferences(stack)).toHaveLength(1); + }); + it('does NOT resolve a bare action name — the tool is the materialised action_', () => { // The ADR-0109 default path is `action_`; naming the raw action is // the near-miss the hint should catch, via the suggestion. const stack = { - objects: [{ name: 'crm_case', actions: [{ name: 'triage_case', label: 'Triage' }] }], + objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }], skills: [{ name: 's', tools: ['triage_case'] }], }; const findings = validateAiToolReferences(stack); @@ -73,7 +126,7 @@ describe('validate-ai-tool-references', () => { it('resolves trailing-wildcard families against the universe', () => { const withActions = { - objects: [{ name: 'crm_case', actions: [{ name: 'triage_case', label: 'T' }] }], + objects: [{ name: 'crm_case', actions: [exposed('triage_case', 'flow')] }], skills: [{ name: 's', tools: ['action_*'] }], }; expect(validateAiToolReferences(withActions)).toEqual([]); diff --git a/packages/lint/src/validate-ai-tool-references.ts b/packages/lint/src/validate-ai-tool-references.ts index 931ab57dd5..a5d4ec5445 100644 --- a/packages/lint/src/validate-ai-tool-references.ts +++ b/packages/lint/src/validate-ai-tool-references.ts @@ -106,6 +106,45 @@ function suggest(target: string, known: Set): string { return best && bestScore <= limit ? ` Did you mean "${best}"?` : ''; } +/** + * Action types with a headless invocation path. `url`/`modal`/`form` are + * Studio-only UI types — the runtime never materialises a tool for them + * because there is nothing to call without the UI collecting input first. + */ +const HEADLESS_ACTION_TYPES = new Set(['script', 'api', 'flow']); + +/** + * Would the runtime materialise an `action_` tool for this action? + * + * Mirrors the STATIC half of the runtime's `actionSkipReason` (ADR-0011 + * opt-in + the headless-path checks). The runtime additionally checks + * service wiring (is the automation service up?), which is not knowable at + * authoring time and is deliberately not modelled here — this predicate is + * about "did the author wire it", not "is the server configured". + * + * Getting this wrong in the permissive direction is worse than having no + * rule: an author who names `action_foo` for a `type:'modal'` action would + * be told the reference resolves, and would ship a skill whose instructions + * promise a capability the agent can never call — the exact failure this + * rule exists to catch. + */ +function materialisesAsTool(action: AnyRec): boolean { + const ai = action.ai; + if (!ai || typeof ai !== 'object') return false; + const aiRec = ai as AnyRec; + // ADR-0011 — opt-in, and `description` is the LLM-facing contract the + // spec requires whenever `exposed` is true. + if (aiRec.exposed !== true) return false; + if (!strName(aiRec.description)) return false; + + const type = strName(action.type); + if (!type || !HEADLESS_ACTION_TYPES.has(type)) return false; + // `script` can carry either a named handler or an inline body; `api` and + // `flow` are dispatched by target. + if (type === 'script') return Boolean(action.target || action.body); + return Boolean(action.target); +} + /** * The full set of tool names resolvable from this stack: declared tool * records ∪ the platform registry ∪ the materialised action family. @@ -121,7 +160,7 @@ function collectToolUniverse(stack: AnyRec): Set { const addActionFamily = (actions: unknown) => { for (const action of asArray(actions)) { const n = strName(action.name); - if (n) universe.add(`action_${n}`); + if (n && materialisesAsTool(action)) universe.add(`action_${n}`); } }; addActionFamily(stack.actions); @@ -132,6 +171,24 @@ function collectToolUniverse(stack: AnyRec): Set { return universe; } +/** + * Actions that exist but are NOT AI-exposed, for the near-miss hint: naming + * `action_foo` when `foo` exists but never materialises is a different + * mistake from naming something fictional, and deserves a different fix. + */ +function collectUnexposedActionNames(stack: AnyRec): Set { + const names = new Set(); + const scan = (actions: unknown) => { + for (const action of asArray(actions)) { + const n = strName(action.name); + if (n && !materialisesAsTool(action)) names.add(n); + } + }; + scan(stack.actions); + for (const obj of asArray(stack.objects)) scan(obj.actions); + return names; +} + /** * Validate every `skill.tools[]` reference in a stack. Returns findings * (empty = clean). @@ -141,6 +198,7 @@ export function validateAiToolReferences(stack: AnyRec): AiToolRefFinding[] { if (!stack || typeof stack !== 'object') return findings; const universe = collectToolUniverse(stack); + const unexposedActions = collectUnexposedActionNames(stack); const resolves = (ref: string): boolean => { if (ref.endsWith('*')) { @@ -164,6 +222,14 @@ export function validateAiToolReferences(stack: AnyRec): AiToolRefFinding[] { if (!ref || resolves(ref)) continue; const isPattern = ref.endsWith('*'); + // The distinct, high-frequency case: the action EXISTS but never + // materialises. "Fictional name" and "real action that isn't exposed" + // need different fixes, so they get different messages. + const unexposed = + !isPattern && ref.startsWith('action_') && unexposedActions.has(ref.slice('action_'.length)) + ? ref.slice('action_'.length) + : undefined; + findings.push({ severity: 'warning', rule: AI_SKILL_TOOL_UNRESOLVED, @@ -171,21 +237,33 @@ export function validateAiToolReferences(stack: AnyRec): AiToolRefFinding[] { path: `skills[${si}].tools[${ti}]`, message: isPattern ? `Skill "${skillName}" subscribes to tool family "${ref}", which matches nothing this ` + - `stack can resolve (no declared tool, no platform tool, and no declarative action ` + - `materialises into it). The subscription contributes zero tools at runtime.` - : `Skill "${skillName}" references tool "${ref}", which resolves to nothing this stack ` + - `can see: not a \`stack.tools\` record, not a platform-registered tool, and not a ` + - `materialised action tool (\`action_\`). The runtime silently drops the ` + - `reference, so the skill's instructions claim a capability the agent does not have — ` + - `the assistant will improvise or fail when asked to use it.` + - suggest(ref, universe), - hint: - `Back "${ref}" with a real executable: declare a declarative action (or flow) and ` + - `reference its materialised tool (\`action_\` — the ADR-0109 default path, no ` + - `tool record needed), reference a platform tool by its registered name, or remove the ` + - `reference and the instructions that mention it. Ignore this only if a runtime plugin ` + - `outside the platform registry provides "${ref}". Family prefixes materialised by the ` + - `runtime: ${PLATFORM_TOOL_FAMILY_PREFIXES.join(', ')}.`, + `stack can resolve (no declared tool, no platform tool, and no AI-exposed declarative ` + + `action materialises into it). The subscription contributes zero tools at runtime.` + : unexposed + ? `Skill "${skillName}" references tool "${ref}", but the action "${unexposed}" does ` + + `not become an AI tool: the runtime materialises \`action_\` only for an ` + + `action that opts in with \`ai.exposed: true\` + \`ai.description\` (ADR-0011) AND ` + + `has a headless path (type \`script\`/\`api\`/\`flow\` with a target or body — ` + + `\`url\`/\`modal\`/\`form\` are UI-only). The reference is dropped at runtime, so ` + + `the skill promises a capability the agent cannot call.` + : `Skill "${skillName}" references tool "${ref}", which resolves to nothing this stack ` + + `can see: not a \`stack.tools\` record, not a platform-registered tool, and not a ` + + `materialised action tool (\`action_\`). The runtime silently drops the ` + + `reference, so the skill's instructions claim a capability the agent does not have — ` + + `the assistant will improvise or fail when asked to use it.` + + suggest(ref, universe), + hint: unexposed + ? `Either opt "${unexposed}" in — set \`ai: { exposed: true, description: '…' }\` (≥40 ` + + `chars, LLM-facing) and give it a headless type — or drop the reference and have the ` + + `skill's instructions recommend the UI action instead. A \`modal\`/\`form\`/\`url\` ` + + `action stays human-driven by design; that is a legitimate answer, not a gap.` + : `Back "${ref}" with a real executable: declare a declarative action (or flow), opt it ` + + `in with \`ai.exposed: true\` + \`ai.description\`, and reference its materialised ` + + `tool (\`action_\` — the ADR-0109 default path, no tool record needed); or ` + + `reference a platform tool by its registered name; or remove the reference and the ` + + `instructions that mention it. Ignore this only if a runtime plugin outside the ` + + `platform registry provides "${ref}". Family prefixes materialised by the runtime: ` + + `${PLATFORM_TOOL_FAMILY_PREFIXES.join(', ')}.`, }); } } diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index f25e3ba0ca..565a545d19 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1618,10 +1618,6 @@ export const enMetadataForms: NonNullable = { label: "Skills", helpText: "Skill names (Agent→Skill→Tool architecture)" }, - tools: { - label: "Tools", - helpText: "Direct tool references (legacy mode)" - }, knowledge: { label: "Knowledge", helpText: "RAG knowledge access configuration" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 716bc318b9..4294acb3f2 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1618,10 +1618,6 @@ export const esESMetadataForms: NonNullable = label: "Habilidades", helpText: "Nombres de skill (arquitectura Agent→Skill→Tool)" }, - tools: { - label: "Herramientas", - helpText: "Referencias directas a herramientas (modo heredado)" - }, knowledge: { label: "Conocimiento", helpText: "Configuración de acceso a conocimiento RAG" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index ea77e31474..78a143edfb 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1618,10 +1618,6 @@ export const jaJPMetadataForms: NonNullable = label: "スキル", helpText: "スキル名(Agent→Skill→Tool アーキテクチャ)" }, - tools: { - label: "ツール", - helpText: "直接ツール参照(レガシーモード)" - }, knowledge: { label: "ナレッジ", helpText: "RAG ナレッジアクセス設定" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index a3f01f614f..c9f74b856d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1618,10 +1618,6 @@ export const zhCNMetadataForms: NonNullable = label: "技能", helpText: "技能名称(Agent→Skill→Tool 架构)" }, - tools: { - label: "工具", - helpText: "直接引用的工具(旧版模式)" - }, knowledge: { label: "知识", helpText: "RAG 知识访问配置" diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index b5dbf170b0..50b2ad2d73 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1845,8 +1845,6 @@ "./ai": [ "AIKnowledgeSchema (const)", "AIModelConfigSchema (const)", - "AITool (type)", - "AIToolSchema (const)", "AIUsageRecord (type)", "AIUsageRecordSchema (const)", "Agent (type)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index fb6e1778a5..0da4ddfbf4 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -3,7 +3,6 @@ "schemas": [ "ai/AIKnowledge", "ai/AIModelConfig", - "ai/AITool", "ai/AIUsageRecord", "ai/Agent", "ai/BlueprintApp", diff --git a/packages/spec/src/ai/agent.form.ts b/packages/spec/src/ai/agent.form.ts index fd42c1d8ba..e9aa023997 100644 --- a/packages/spec/src/ai/agent.form.ts +++ b/packages/spec/src/ai/agent.form.ts @@ -39,10 +39,9 @@ export const agentForm = defineForm({ { name: 'capabilities', label: 'Capabilities', - description: 'Skills, tools, and knowledge sources the agent can use.', + description: 'Skills and knowledge sources the agent can use.', fields: [ { field: 'skills', widget: 'string-tags', helpText: 'Skill names (Agent→Skill→Tool architecture)' }, - { field: 'tools', type: 'repeater', helpText: 'Direct tool references (legacy mode)' }, { field: 'knowledge', type: 'composite', helpText: 'RAG knowledge access configuration' }, ], }, diff --git a/packages/spec/src/ai/agent.test.ts b/packages/spec/src/ai/agent.test.ts index 41386776aa..a5a946ad56 100644 --- a/packages/spec/src/ai/agent.test.ts +++ b/packages/spec/src/ai/agent.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { AgentSchema, AIModelConfigSchema, - AIToolSchema, AIKnowledgeSchema, StructuredOutputFormatSchema, StructuredOutputConfigSchema, @@ -68,27 +67,19 @@ describe('AIModelConfigSchema', () => { }); }); -describe('AIToolSchema', () => { - it('should accept all tool types', () => { - const types = ['action', 'flow', 'query', 'vector_search'] as const; - - types.forEach(type => { - const tool = { - type, - name: 'test_tool', - }; - expect(() => AIToolSchema.parse(tool)).not.toThrow(); +describe('agent.tools removal (ADR-0064 / #3820)', () => { + it('strips a legacy inline tools array instead of carrying it', () => { + // The field is gone, so Zod drops it rather than handing the runtime a + // second, unscoped tool slot to disagree with the skills. Authoring one + // is a no-op, not a parse error — an existing stack keeps parsing. + const parsed = AgentSchema.parse({ + name: 'legacy', + label: 'Legacy', + role: 'r', + instructions: 'x', + tools: [{ type: 'action', name: 'create_ticket' }], }); - }); - - it('should accept tool with description', () => { - const tool = { - type: 'action' as const, - name: 'create_ticket', - description: 'Creates a new support ticket in the system', - }; - - expect(() => AIToolSchema.parse(tool)).not.toThrow(); + expect(parsed).not.toHaveProperty('tools'); }); }); @@ -217,30 +208,20 @@ describe('AgentSchema', () => { }); describe('Tools and Capabilities', () => { - it('should accept agent with tools', () => { + it('carries capability through skills, the only tool-bearing slot', () => { + // ADR-0064 — an agent's tool set is exactly the union of its + // surface-compatible skills' tools. There is no direct-tool slot. const agent: Agent = { name: 'workflow_agent', label: 'Workflow Agent', role: 'Automation Specialist', instructions: 'Execute workflows and actions.', - tools: [ - { - type: 'action', - name: 'send_email', - description: 'Send email to users', - }, - { - type: 'flow', - name: 'approval_workflow', - }, - { - type: 'query', - name: 'get_pending_tasks', - }, - ], + skills: ['approvals', 'notifications'], }; - expect(() => AgentSchema.parse(agent)).not.toThrow(); + const result = AgentSchema.parse(agent); + expect(result.skills).toEqual(['approvals', 'notifications']); + expect(result).not.toHaveProperty('tools'); }); it('should accept agent with knowledge base', () => { @@ -291,21 +272,19 @@ describe('AgentSchema', () => { expect(result.skills).toContain('case_management'); }); - it('should accept agent with both skills and tools fallback', () => { - const agent: Agent = { + it('keeps skills and drops a legacy inline tools array', () => { + const agent = { name: 'hybrid_agent', label: 'Hybrid Agent', role: 'Versatile Assistant', - instructions: 'Use skills primarily, tools as fallback.', + instructions: 'Skills carry the capability.', skills: ['case_management'], - tools: [ - { type: 'action', name: 'send_email' }, - ], + tools: [{ type: 'action', name: 'send_email' }], }; const result = AgentSchema.parse(agent); expect(result.skills).toHaveLength(1); - expect(result.tools).toHaveLength(1); + expect(result).not.toHaveProperty('tools'); }); it('should accept agent with permissions', () => { @@ -793,18 +772,15 @@ describe('defineAgent', () => { expect(result.active).toBe(true); }); - it('should accept agent with tools', () => { + it('should accept agent with skills', () => { const result = defineAgent({ name: 'smart_agent', label: 'Smart Agent', role: 'Analyst', instructions: 'Analyze data.', - tools: [ - { type: 'action', name: 'create_report' }, - { type: 'query', name: 'search_records' }, - ], + skills: ['reporting', 'record_search'], }); - expect(result.tools).toHaveLength(2); + expect(result.skills).toHaveLength(2); }); it('should throw on invalid agent name', () => { diff --git a/packages/spec/src/ai/agent.zod.ts b/packages/spec/src/ai/agent.zod.ts index 29553132c0..c20656239d 100644 --- a/packages/spec/src/ai/agent.zod.ts +++ b/packages/spec/src/ai/agent.zod.ts @@ -18,16 +18,6 @@ export const AIModelConfigSchema = lazySchema(() => z.object({ topP: z.number().optional(), })); -/** - * AI Tool Definition - * References to Actions, Flows, or Objects available to the Agent. - */ -export const AIToolSchema = lazySchema(() => z.object({ - type: z.enum(['action', 'flow', 'query', 'vector_search']), - name: z.string().describe('Reference name (Action Name, Flow Name)'), - description: z.string().optional().describe('Override description for the LLM'), -})); - /** * AI Knowledge Base * RAG configuration. @@ -110,8 +100,9 @@ export type StructuredOutputConfig = z.infer z.object({ /** Identity */ @@ -166,8 +143,15 @@ export const AgentSchema = lazySchema(() => z.object({ /** Capabilities — Skill-based (primary) */ skills: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).optional().describe('Skill names to attach (Agent→Skill→Tool architecture)'), - /** Capabilities — Direct tool references (fallback / legacy) */ - tools: z.array(AIToolSchema).optional().describe('Direct tool references (legacy fallback)'), + // `tools` (the legacy inline `{type,name,description}[]` fallback) was + // REMOVED — ADR-0064's central invariant is "an agent's tool set is the + // union of its surface-compatible skills' tools; nothing falls through to + // the global registry", and this field was the one seam that broke it: the + // runtime resolved `agent.tools[].name` against the FULL registry with no + // surface check, so an `ask`-surface agent could name an authoring tool and + // get it. The invariant is now structural — there is no second slot to + // disagree with the skills — rather than a rule every reader must remember + // (ADR-0049 "design+enforce or remove"). Attach capability via `skills`. /** Knowledge */ knowledge: AIKnowledgeSchema.optional().describe('RAG access'), @@ -271,21 +255,9 @@ export const AgentSchema = lazySchema(() => z.object({ * skills: ['case_management', 'knowledge_search'], * }); * ``` - * - * @example Legacy Tool References (backward-compatible) - * ```ts - * const supportAgent = defineAgent({ - * name: 'support_agent', - * label: 'Support Agent', - * role: 'Senior Support Engineer', - * instructions: 'You help customers resolve technical issues.', - * tools: [{ type: 'action', name: 'create_ticket' }], - * }); - * ``` */ export function defineAgent(config: z.input): Agent { return AgentSchema.parse(config); } export type Agent = z.infer; -export type AITool = z.infer;