@zhin.js/agent-feature 1.0.13 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,40 +1,59 @@
1
1
  # @zhin.js/agent-feature
2
2
 
3
- 下一代 Markdown Agent Feature。它从 `agents/<name>.agent.md` 构造 immutable Agent definition,不读取旧分形 `agent.ts`、`instructions.md` 或模块级 SubAgent registry。
3
+ 目录化子 Agent Feature。主 Agent 使用插件根目录的标准 `AGENTS.md`;命名子 Agent 使用 `agents/<name>/` 自包含模块。运行时只识别 `agent.json`,不扫描单文件 `.agent.md`、`agent.ts` 或 `instructions.md`。
4
4
 
5
5
  ## 目录约定
6
6
 
7
7
  ```text
8
- agents/
9
- ├── planner.agent.md
10
- └── reviewer.agent.md
8
+ my-plugin/
9
+ ├── AGENTS.md
10
+ └── agents/
11
+ └── backend-engineer/
12
+ ├── agent.json
13
+ ├── system.md
14
+ ├── boundaries.md
15
+ ├── conventions.md
16
+ ├── workflows/
17
+ │ └── create-api.md
18
+ ├── tools/
19
+ │ └── schema-validator/
20
+ │ └── index.ts
21
+ ├── skills/
22
+ │ └── db-migration/
23
+ │ ├── SKILL.md
24
+ │ └── tools/
25
+ │ └── schema-check/
26
+ │ └── index.ts
27
+ ├── hooks/
28
+ │ └── audit/
29
+ │ └── index.ts
30
+ └── knowledge/
31
+ └── internal-architecture.md
11
32
  ```
12
33
 
13
- Agent 目录只允许一级、精确 `.agent.md` 后缀。`planner.agent.md` 的 local name 是 `planner`;child Plugin 的 qualified name 自动包含 Plugin instance path。
14
-
15
- ## Markdown 契约
16
-
17
- ```markdown
18
- # Planner
19
-
20
- Break the request into verifiable steps. Prefer reversible actions.
34
+ `agents/<name>` 必须使用小写 kebab-case。四个核心文件缺一不可;`workflows/`、`tools/`、`skills/`、`hooks/`、`knowledge/` 可省略。`entry_points` 决定提示词的组合顺序,并且必须包含三个核心 Markdown 文件。
35
+
36
+ ```json
37
+ {
38
+ "name": "Backend Engineer Agent",
39
+ "version": "1.0.0",
40
+ "description": "负责后端业务逻辑与 API 设计",
41
+ "trigger_rules": {
42
+ "file_patterns": ["src/backend/**", "database/**"],
43
+ "keywords": ["api", "service", "migration"]
44
+ },
45
+ "entry_points": ["system.md", "boundaries.md", "conventions.md"],
46
+ "permissions": ["role(developer)"]
47
+ }
21
48
  ```
22
49
 
23
- 完整 Markdown 是 `instructions` SSOT。首个 heading 作为 description;没有 heading 时使用文件 identity。Feature 不绑定模型、system prompt 模板、tool policy 或 session store,这些属于 orchestrator adapter。
24
-
25
- 单文件插件可用 `setup({ addAgent })` 注册 Markdown Agent,例如
26
- `addAgent('planner', '# Planner\n\nBreak work into verifiable steps.')`;目录模式和 setup
27
- 模式共用 AgentIndex。
28
-
29
- ## Projection
30
-
31
- `AgentIndex.list()` 返回全树 qualified descriptors;`visible(owner)` 和 `get(owner, name)` 使用 nearest-owner inheritance。不同 Plugin owner 的同名 Agent 不通过扫描顺序覆盖。
50
+ `system.md` 定义身份与任务,`boundaries.md` 定义权限和行为边界,`conventions.md` 只扩展、具象化根 `AGENTS.md`。反复出现的项目坑应补充到 `conventions.md`。
32
51
 
33
- Agent definition 是纯数据,没有 runtime disposer。单文件 HMR 只替换 Agent Slot,旧 turn lease 继续使用旧 instructions。
52
+ `workflows/` 与 `knowledge/` 进入 Agent 指令;`tools/<name>/index.ts` 是由 `@zhin.js/tool` 校验、审批和审计的私有 Tool;`skills/<name>/SKILL.md` 是由 `@zhin.js/skill` 投影的私有 Skill。目录结构只声明归属,不绕过 Feature 准入。
34
53
 
35
- ## 依赖
54
+ ## Projection 与热重载
36
55
 
37
- 只依赖 Next Kernel 与 Feature Kit,不引入 AI SDK、模型 provider、Markdown/YAML parser 或数据库。
56
+ `AgentIndex.list()` 返回全树 qualified descriptors;`visible(owner)` 和 `get(owner, name)` 使用 nearest-owner inheritance。Agent 的 manifest、入口文件及资源文件变化只替换对应 Slot,进行中的 turn 继续使用原 generation。
38
57
 
39
58
  ## 验证
40
59
 
@@ -1,15 +1,49 @@
1
1
  import type { ValidationContext } from '@zhin.js/feature-kit';
2
2
  declare const agentBrand: "zhin.agent/1";
3
+ export type AgentScope = 'private' | 'group' | 'channel';
4
+ export interface AgentTriggerRules {
5
+ readonly filePatterns: readonly string[];
6
+ readonly keywords: readonly string[];
7
+ }
8
+ export interface AgentResource {
9
+ readonly path: string;
10
+ readonly content: string;
11
+ }
12
+ export interface AgentPackageSource {
13
+ readonly manifest: unknown;
14
+ readonly files: Readonly<Record<string, string>>;
15
+ readonly workflows: readonly AgentResource[];
16
+ readonly knowledge: readonly AgentResource[];
17
+ readonly privateToolNames?: readonly string[];
18
+ readonly privateSkillNames?: readonly string[];
19
+ }
3
20
  export interface AgentDefinition {
4
21
  readonly $feature: typeof agentBrand;
22
+ /** Stable capability id derived from agents/<name>/. */
5
23
  readonly name: string;
24
+ /** Human-readable name declared by agent.json. */
25
+ readonly displayName: string;
26
+ readonly version: string;
6
27
  readonly description: string;
28
+ readonly triggerRules: AgentTriggerRules;
29
+ readonly entryPoints: readonly string[];
7
30
  readonly instructions: string;
31
+ readonly workflows: readonly AgentResource[];
32
+ readonly knowledge: readonly AgentResource[];
33
+ readonly toolNames?: readonly string[];
34
+ readonly skillNames?: readonly string[];
35
+ readonly disallowedTools?: readonly string[];
36
+ readonly tags?: readonly string[];
37
+ readonly role?: string;
38
+ readonly contextMode?: 'fork' | 'fresh';
39
+ readonly maxIterations?: number;
40
+ readonly model?: string;
41
+ readonly provider?: string;
42
+ readonly effort?: 'low' | 'medium' | 'high' | 'max';
43
+ readonly memory?: 'user' | 'session' | 'agent';
44
+ readonly platforms?: readonly string[];
45
+ readonly scopes?: readonly AgentScope[];
46
+ readonly permissions?: readonly string[];
8
47
  }
9
- declare module '@zhin.js/plugin-runtime' {
10
- interface PluginSetupContext<TConfig = unknown> {
11
- addAgent(localName: string, markdown: string): void;
12
- }
13
- }
14
- export declare function parseAgentMarkdown(value: unknown, context: ValidationContext): AgentDefinition;
48
+ export declare function parseAgentPackage(value: unknown, context: ValidationContext): AgentDefinition;
15
49
  export {};
package/lib/definition.js CHANGED
@@ -1,18 +1,157 @@
1
1
  const agentBrand = 'zhin.agent/1';
2
- export function parseAgentMarkdown(value, context) {
3
- if (typeof value !== 'string' || !value.trim()) {
4
- throw new TypeError(`Agent ${context.source} must contain Markdown instructions`);
2
+ const AGENT_NAME = /^[a-z0-9][a-z0-9-]*$/u;
3
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
4
+ const ENTRY_POINT = /^[a-z0-9][a-z0-9._-]*\.md$/u;
5
+ const REQUIRED_ENTRY_POINTS = ['system.md', 'boundaries.md', 'conventions.md'];
6
+ export function parseAgentPackage(value, context) {
7
+ if (!isRecord(value))
8
+ throw new TypeError(`Agent ${context.source} must be a package source`);
9
+ const source = value;
10
+ const manifest = parseManifest(source.manifest, context);
11
+ if (!AGENT_NAME.test(context.localName)) {
12
+ throw new TypeError(`Agent directory ${context.localName} must use lowercase kebab-case`);
13
+ }
14
+ const files = isRecord(source.files) ? source.files : {};
15
+ const entryPoints = stringList(manifest.entry_points, 'entry_points', true);
16
+ for (const required of REQUIRED_ENTRY_POINTS) {
17
+ if (!entryPoints.includes(required)) {
18
+ throw new TypeError(`Agent ${context.source} entry_points must include ${required}`);
19
+ }
5
20
  }
21
+ for (const entry of entryPoints) {
22
+ if (!ENTRY_POINT.test(entry)) {
23
+ throw new TypeError(`Agent ${context.source} entry point must be a root Markdown file: ${entry}`);
24
+ }
25
+ if (typeof files[entry] !== 'string' || !files[entry].trim()) {
26
+ throw new TypeError(`Agent ${context.source} entry point is missing or empty: ${entry}`);
27
+ }
28
+ }
29
+ const workflows = resources(source.workflows, 'workflows');
30
+ const knowledge = resources(source.knowledge, 'knowledge');
6
31
  return Object.freeze({
7
32
  $feature: agentBrand,
8
33
  name: context.localName,
9
- description: markdownSummary(value, context.localName),
10
- instructions: value,
34
+ displayName: nonEmptyText(manifest.name, 'name'),
35
+ version: semanticVersion(manifest.version),
36
+ description: nonEmptyText(manifest.description, 'description'),
37
+ triggerRules: Object.freeze({
38
+ filePatterns: stringList(manifest.trigger_rules.file_patterns ?? [], 'trigger_rules.file_patterns'),
39
+ keywords: stringList(manifest.trigger_rules.keywords ?? [], 'trigger_rules.keywords'),
40
+ }),
41
+ entryPoints,
42
+ instructions: assembleInstructions(entryPoints, files, workflows, knowledge),
43
+ workflows,
44
+ knowledge,
45
+ toolNames: combinedNames(manifest.tools, source.privateToolNames, 'tools'),
46
+ skillNames: combinedNames(manifest.skills, source.privateSkillNames, 'skills'),
47
+ disallowedTools: optionalStringList(manifest.disallowed_tools, 'disallowed_tools'),
48
+ tags: optionalStringList(manifest.tags, 'tags'),
49
+ role: optionalText(manifest.role, 'role'),
50
+ contextMode: optionalEnum(manifest.context_mode, 'context_mode', ['fork', 'fresh']),
51
+ maxIterations: optionalPositiveInteger(manifest.max_iterations, 'max_iterations'),
52
+ model: optionalText(manifest.model, 'model'),
53
+ provider: optionalText(manifest.provider, 'provider'),
54
+ effort: optionalEnum(manifest.effort, 'effort', ['low', 'medium', 'high', 'max']),
55
+ memory: optionalEnum(manifest.memory, 'memory', ['user', 'session', 'agent']),
56
+ platforms: optionalStringList(manifest.platforms, 'platforms'),
57
+ scopes: optionalScopes(manifest.scopes),
58
+ permissions: optionalStringList(manifest.permissions, 'permissions'),
11
59
  });
12
60
  }
13
- function markdownSummary(markdown, fallback) {
14
- const line = markdown.split(/\r?\n/u)
15
- .map((value) => value.trim())
16
- .find((value) => /^#+\s+\S/u.test(value));
17
- return line?.replace(/^#+\s*/u, '') ?? fallback;
61
+ function assembleInstructions(entryPoints, files, workflows, knowledge) {
62
+ const sections = entryPoints.map((entry) => `## ${entry}\n\n${String(files[entry]).trim()}`);
63
+ for (const resource of [...workflows, ...knowledge]) {
64
+ sections.push(`## ${resource.path}\n\n${resource.content.trim()}`);
65
+ }
66
+ return sections.join('\n\n');
67
+ }
68
+ function parseManifest(value, context) {
69
+ if (!isRecord(value))
70
+ throw new TypeError(`Agent ${context.source} agent.json must contain an object`);
71
+ const allowed = new Set([
72
+ 'name', 'version', 'description', 'trigger_rules', 'entry_points', 'tools', 'skills',
73
+ 'disallowed_tools', 'tags', 'role', 'context_mode', 'max_iterations', 'model',
74
+ 'provider', 'effort', 'memory', 'platforms', 'scopes', 'permissions',
75
+ ]);
76
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
77
+ if (unknown.length)
78
+ throw new TypeError(`Agent ${context.source} agent.json has unknown fields: ${unknown.join(', ')}`);
79
+ if (!isRecord(value.trigger_rules)) {
80
+ throw new TypeError(`Agent ${context.source} trigger_rules must be an object`);
81
+ }
82
+ const triggerUnknown = Object.keys(value.trigger_rules)
83
+ .filter((key) => key !== 'file_patterns' && key !== 'keywords');
84
+ if (triggerUnknown.length) {
85
+ throw new TypeError(`Agent ${context.source} trigger_rules has unknown fields: ${triggerUnknown.join(', ')}`);
86
+ }
87
+ return value;
88
+ }
89
+ function resources(value, field) {
90
+ if (!Array.isArray(value))
91
+ throw new TypeError(`Agent package ${field} must be an array`);
92
+ return Object.freeze(value.map((item) => {
93
+ if (!isRecord(item) || typeof item.path !== 'string' || typeof item.content !== 'string') {
94
+ throw new TypeError(`Agent package ${field} entries must contain path and content strings`);
95
+ }
96
+ return Object.freeze({ path: item.path, content: item.content });
97
+ }));
98
+ }
99
+ function stringList(value, field, required = false) {
100
+ if (!Array.isArray(value) || (required && value.length === 0)
101
+ || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
102
+ throw new TypeError(`Agent agent.json ${field} must contain non-empty strings`);
103
+ }
104
+ const normalized = value.map((entry) => String(entry).trim());
105
+ if (new Set(normalized).size !== normalized.length) {
106
+ throw new TypeError(`Agent agent.json ${field} must not contain duplicates`);
107
+ }
108
+ return Object.freeze(normalized);
109
+ }
110
+ function optionalStringList(value, field) {
111
+ return value === undefined ? undefined : stringList(value, field);
112
+ }
113
+ function combinedNames(declared, discovered, field) {
114
+ const values = [...(optionalStringList(declared, field) ?? []), ...(discovered ?? [])];
115
+ return values.length ? Object.freeze([...new Set(values)]) : undefined;
116
+ }
117
+ function nonEmptyText(value, field) {
118
+ if (typeof value !== 'string' || !value.trim()) {
119
+ throw new TypeError(`Agent agent.json ${field} must be a non-empty string`);
120
+ }
121
+ return value.trim();
122
+ }
123
+ function optionalText(value, field) {
124
+ return value === undefined ? undefined : nonEmptyText(value, field);
125
+ }
126
+ function semanticVersion(value) {
127
+ const version = nonEmptyText(value, 'version');
128
+ if (!VERSION.test(version))
129
+ throw new TypeError('Agent agent.json version must be semantic version');
130
+ return version;
131
+ }
132
+ function optionalPositiveInteger(value, field) {
133
+ if (value === undefined)
134
+ return undefined;
135
+ if (!Number.isInteger(value) || Number(value) < 1) {
136
+ throw new TypeError(`Agent agent.json ${field} must be a positive integer`);
137
+ }
138
+ return Number(value);
139
+ }
140
+ function optionalEnum(value, field, allowed) {
141
+ if (value === undefined)
142
+ return undefined;
143
+ if (typeof value !== 'string' || !allowed.includes(value)) {
144
+ throw new TypeError(`Agent agent.json ${field} must be one of: ${allowed.join(', ')}`);
145
+ }
146
+ return value;
147
+ }
148
+ function optionalScopes(value) {
149
+ const scopes = optionalStringList(value, 'scopes');
150
+ if (scopes?.some((scope) => scope !== 'private' && scope !== 'group' && scope !== 'channel')) {
151
+ throw new TypeError('Agent agent.json scopes must be private, group, or channel');
152
+ }
153
+ return scopes;
154
+ }
155
+ function isRecord(value) {
156
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
157
  }
package/lib/provider.js CHANGED
@@ -1,36 +1,80 @@
1
- import { join } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
2
  import { featureId } from '@zhin.js/plugin-runtime';
3
3
  import { defineFeatureProvider, } from '@zhin.js/feature-kit';
4
4
  import { AgentIndex } from './agent-index.js';
5
- import { parseAgentMarkdown } from './definition.js';
5
+ import { parseAgentPackage } from './definition.js';
6
6
  export const agentFeatureId = featureId('zhin.agent');
7
- const agentFiles = {
8
- id: 'agent-markdown',
7
+ const agentPackages = {
8
+ id: 'agent-package',
9
9
  async *discover(context) {
10
- const directory = join(context.packageRoot, 'agents');
11
- const entries = [...await context.host.list(directory)]
10
+ const root = join(context.packageRoot, 'agents');
11
+ const entries = [...await context.host.list(root)]
12
12
  .sort((left, right) => left.name.localeCompare(right.name));
13
13
  for (const entry of entries) {
14
- if (entry.kind !== 'file' || !isAgentFile(entry.name))
14
+ if (entry.kind !== 'directory' || !isAgentName(entry.name))
15
15
  continue;
16
+ const directory = join(root, entry.name);
17
+ const children = await context.host.list(directory);
18
+ if (!children.some((child) => child.kind === 'file' && child.name === 'agent.json'))
19
+ continue;
20
+ const relatedSources = [
21
+ ...children
22
+ .filter((child) => child.kind === 'file' && child.name !== 'agent.json')
23
+ .map((child) => join(directory, child.name)),
24
+ ...await discoverRelatedSources(context.host, directory),
25
+ ];
16
26
  yield {
17
- localName: entry.name.slice(0, -'.agent.md'.length),
18
- source: join(directory, entry.name),
27
+ localName: entry.name,
28
+ source: join(directory, 'agent.json'),
29
+ relatedSources: Object.freeze(relatedSources),
19
30
  target: 'server',
20
31
  };
21
32
  }
22
33
  },
23
- load(source, context) {
24
- return context.host.readText(source.source);
34
+ async load(source, context) {
35
+ const directory = dirname(source.source);
36
+ const manifest = JSON.parse(await context.host.readText(source.source));
37
+ const files = await readRootFiles(context.host, directory);
38
+ return {
39
+ manifest,
40
+ files,
41
+ workflows: await readResourceDirectory(context.host, directory, 'workflows'),
42
+ knowledge: await readResourceDirectory(context.host, directory, 'knowledge'),
43
+ privateToolNames: (await childDirectories(context.host, join(directory, 'tools')))
44
+ .map((name) => `agent__${source.localName}__${name}`),
45
+ privateSkillNames: (await childDirectories(context.host, join(directory, 'skills')))
46
+ .map((name) => `agent__${source.localName}__${name}`),
47
+ };
25
48
  },
26
49
  };
50
+ async function discoverRelatedSources(host, directory) {
51
+ const result = [];
52
+ for (const name of ['workflows', 'tools', 'skills', 'knowledge']) {
53
+ await collectFiles(host, join(directory, name), result);
54
+ }
55
+ return result;
56
+ }
57
+ async function collectFiles(host, directory, result) {
58
+ for (const entry of await host.list(directory)) {
59
+ const target = join(directory, entry.name);
60
+ if (entry.kind === 'directory' && isAgentName(entry.name))
61
+ await collectFiles(host, target, result);
62
+ else if (entry.kind === 'file')
63
+ result.push(target);
64
+ }
65
+ }
66
+ async function childDirectories(host, directory) {
67
+ return [...await host.list(directory)]
68
+ .filter((entry) => entry.kind === 'directory' && isAgentName(entry.name))
69
+ .map((entry) => entry.name)
70
+ .sort();
71
+ }
27
72
  const agentFeature = defineFeatureProvider({
28
73
  protocol: 1,
29
74
  id: agentFeatureId,
30
75
  authoring: {
31
- setupMethod: 'addAgent',
32
- conventions: [agentFiles],
33
- validate: parseAgentMarkdown,
76
+ conventions: [agentPackages],
77
+ validate: parseAgentPackage,
34
78
  },
35
79
  runtime: {
36
80
  project(slots, context) {
@@ -38,8 +82,28 @@ const agentFeature = defineFeatureProvider({
38
82
  },
39
83
  },
40
84
  });
41
- function isAgentFile(value) {
42
- return /^[a-z0-9][a-z0-9-]*\.agent\.md$/u.test(value);
85
+ async function readRootFiles(host, directory) {
86
+ const entries = await host.list(directory);
87
+ const files = await Promise.all(entries
88
+ .filter((entry) => entry.kind === 'file' && entry.name !== 'agent.json')
89
+ .map(async (entry) => [entry.name, await host.readText(join(directory, entry.name))]));
90
+ return Object.freeze(Object.fromEntries(files));
91
+ }
92
+ async function readResourceDirectory(host, agentDirectory, name) {
93
+ const directory = join(agentDirectory, name);
94
+ const entries = [...await host.list(directory)]
95
+ .filter((entry) => entry.kind === 'file' && isResourceFile(entry.name))
96
+ .sort((left, right) => left.name.localeCompare(right.name));
97
+ return Promise.all(entries.map(async (entry) => Object.freeze({
98
+ path: `${name}/${entry.name}`,
99
+ content: await host.readText(join(directory, entry.name)),
100
+ })));
101
+ }
102
+ function isAgentName(value) {
103
+ return /^[a-z0-9][a-z0-9-]*$/u.test(value);
104
+ }
105
+ function isResourceFile(value) {
106
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(?:md|txt|json|ya?ml|csv|sh)$/u.test(value);
43
107
  }
44
108
  export { agentFeature };
45
109
  export default agentFeature;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/agent-feature",
3
- "version": "1.0.13",
3
+ "version": "1.1.1",
4
4
  "description": "Convention-based Markdown Agent Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -17,8 +17,8 @@
17
17
  "src"
18
18
  ],
19
19
  "dependencies": {
20
- "@zhin.js/feature-kit": "1.0.13",
21
- "@zhin.js/plugin-runtime": "1.1.8"
20
+ "@zhin.js/feature-kit": "1.1.1",
21
+ "@zhin.js/plugin-runtime": "1.1.10"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^26.1.2",
package/src/definition.ts CHANGED
@@ -1,37 +1,254 @@
1
1
  import type { ValidationContext } from '@zhin.js/feature-kit';
2
2
 
3
3
  const agentBrand = 'zhin.agent/1' as const;
4
+ const AGENT_NAME = /^[a-z0-9][a-z0-9-]*$/u;
5
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
6
+ const ENTRY_POINT = /^[a-z0-9][a-z0-9._-]*\.md$/u;
7
+ const REQUIRED_ENTRY_POINTS = ['system.md', 'boundaries.md', 'conventions.md'] as const;
8
+
9
+ export type AgentScope = 'private' | 'group' | 'channel';
10
+
11
+ export interface AgentTriggerRules {
12
+ readonly filePatterns: readonly string[];
13
+ readonly keywords: readonly string[];
14
+ }
15
+
16
+ export interface AgentResource {
17
+ readonly path: string;
18
+ readonly content: string;
19
+ }
20
+
21
+ export interface AgentPackageSource {
22
+ readonly manifest: unknown;
23
+ readonly files: Readonly<Record<string, string>>;
24
+ readonly workflows: readonly AgentResource[];
25
+ readonly knowledge: readonly AgentResource[];
26
+ readonly privateToolNames?: readonly string[];
27
+ readonly privateSkillNames?: readonly string[];
28
+ }
4
29
 
5
30
  export interface AgentDefinition {
6
31
  readonly $feature: typeof agentBrand;
32
+ /** Stable capability id derived from agents/<name>/. */
7
33
  readonly name: string;
34
+ /** Human-readable name declared by agent.json. */
35
+ readonly displayName: string;
36
+ readonly version: string;
8
37
  readonly description: string;
38
+ readonly triggerRules: AgentTriggerRules;
39
+ readonly entryPoints: readonly string[];
9
40
  readonly instructions: string;
41
+ readonly workflows: readonly AgentResource[];
42
+ readonly knowledge: readonly AgentResource[];
43
+ readonly toolNames?: readonly string[];
44
+ readonly skillNames?: readonly string[];
45
+ readonly disallowedTools?: readonly string[];
46
+ readonly tags?: readonly string[];
47
+ readonly role?: string;
48
+ readonly contextMode?: 'fork' | 'fresh';
49
+ readonly maxIterations?: number;
50
+ readonly model?: string;
51
+ readonly provider?: string;
52
+ readonly effort?: 'low' | 'medium' | 'high' | 'max';
53
+ readonly memory?: 'user' | 'session' | 'agent';
54
+ readonly platforms?: readonly string[];
55
+ readonly scopes?: readonly AgentScope[];
56
+ readonly permissions?: readonly string[];
10
57
  }
11
58
 
12
- declare module '@zhin.js/plugin-runtime' {
13
- // Type parameter name must match the base PluginSetupContext declaration (TS2428).
14
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
- interface PluginSetupContext<TConfig = unknown> {
16
- addAgent(localName: string, markdown: string): void;
17
- }
59
+ interface AgentManifest {
60
+ readonly name: string;
61
+ readonly version: string;
62
+ readonly description: string;
63
+ readonly trigger_rules: {
64
+ readonly file_patterns?: readonly string[];
65
+ readonly keywords?: readonly string[];
66
+ };
67
+ readonly entry_points: readonly string[];
68
+ readonly tools?: readonly string[];
69
+ readonly skills?: readonly string[];
70
+ readonly disallowed_tools?: readonly string[];
71
+ readonly tags?: readonly string[];
72
+ readonly role?: string;
73
+ readonly context_mode?: 'fork' | 'fresh';
74
+ readonly max_iterations?: number;
75
+ readonly model?: string;
76
+ readonly provider?: string;
77
+ readonly effort?: 'low' | 'medium' | 'high' | 'max';
78
+ readonly memory?: 'user' | 'session' | 'agent';
79
+ readonly platforms?: readonly string[];
80
+ readonly scopes?: readonly AgentScope[];
81
+ readonly permissions?: readonly string[];
18
82
  }
19
83
 
20
- export function parseAgentMarkdown(value: unknown, context: ValidationContext): AgentDefinition {
21
- if (typeof value !== 'string' || !value.trim()) {
22
- throw new TypeError(`Agent ${context.source} must contain Markdown instructions`);
84
+ export function parseAgentPackage(value: unknown, context: ValidationContext): AgentDefinition {
85
+ if (!isRecord(value)) throw new TypeError(`Agent ${context.source} must be a package source`);
86
+ const source = value as unknown as AgentPackageSource;
87
+ const manifest = parseManifest(source.manifest, context);
88
+ if (!AGENT_NAME.test(context.localName)) {
89
+ throw new TypeError(`Agent directory ${context.localName} must use lowercase kebab-case`);
90
+ }
91
+ const files = isRecord(source.files) ? source.files : {};
92
+ const entryPoints = stringList(manifest.entry_points, 'entry_points', true);
93
+ for (const required of REQUIRED_ENTRY_POINTS) {
94
+ if (!entryPoints.includes(required)) {
95
+ throw new TypeError(`Agent ${context.source} entry_points must include ${required}`);
96
+ }
23
97
  }
98
+ for (const entry of entryPoints) {
99
+ if (!ENTRY_POINT.test(entry)) {
100
+ throw new TypeError(`Agent ${context.source} entry point must be a root Markdown file: ${entry}`);
101
+ }
102
+ if (typeof files[entry] !== 'string' || !files[entry].trim()) {
103
+ throw new TypeError(`Agent ${context.source} entry point is missing or empty: ${entry}`);
104
+ }
105
+ }
106
+ const workflows = resources(source.workflows, 'workflows');
107
+ const knowledge = resources(source.knowledge, 'knowledge');
24
108
  return Object.freeze({
25
109
  $feature: agentBrand,
26
110
  name: context.localName,
27
- description: markdownSummary(value, context.localName),
28
- instructions: value,
111
+ displayName: nonEmptyText(manifest.name, 'name'),
112
+ version: semanticVersion(manifest.version),
113
+ description: nonEmptyText(manifest.description, 'description'),
114
+ triggerRules: Object.freeze({
115
+ filePatterns: stringList(manifest.trigger_rules.file_patterns ?? [], 'trigger_rules.file_patterns'),
116
+ keywords: stringList(manifest.trigger_rules.keywords ?? [], 'trigger_rules.keywords'),
117
+ }),
118
+ entryPoints,
119
+ instructions: assembleInstructions(entryPoints, files, workflows, knowledge),
120
+ workflows,
121
+ knowledge,
122
+ toolNames: combinedNames(manifest.tools, source.privateToolNames, 'tools'),
123
+ skillNames: combinedNames(manifest.skills, source.privateSkillNames, 'skills'),
124
+ disallowedTools: optionalStringList(manifest.disallowed_tools, 'disallowed_tools'),
125
+ tags: optionalStringList(manifest.tags, 'tags'),
126
+ role: optionalText(manifest.role, 'role'),
127
+ contextMode: optionalEnum(manifest.context_mode, 'context_mode', ['fork', 'fresh']),
128
+ maxIterations: optionalPositiveInteger(manifest.max_iterations, 'max_iterations'),
129
+ model: optionalText(manifest.model, 'model'),
130
+ provider: optionalText(manifest.provider, 'provider'),
131
+ effort: optionalEnum(manifest.effort, 'effort', ['low', 'medium', 'high', 'max']),
132
+ memory: optionalEnum(manifest.memory, 'memory', ['user', 'session', 'agent']),
133
+ platforms: optionalStringList(manifest.platforms, 'platforms'),
134
+ scopes: optionalScopes(manifest.scopes),
135
+ permissions: optionalStringList(manifest.permissions, 'permissions'),
29
136
  });
30
137
  }
31
138
 
32
- function markdownSummary(markdown: string, fallback: string): string {
33
- const line = markdown.split(/\r?\n/u)
34
- .map((value) => value.trim())
35
- .find((value) => /^#+\s+\S/u.test(value));
36
- return line?.replace(/^#+\s*/u, '') ?? fallback;
139
+ function assembleInstructions(
140
+ entryPoints: readonly string[],
141
+ files: Readonly<Record<string, unknown>>,
142
+ workflows: readonly AgentResource[],
143
+ knowledge: readonly AgentResource[],
144
+ ): string {
145
+ const sections = entryPoints.map((entry) => `## ${entry}\n\n${String(files[entry]).trim()}`);
146
+ for (const resource of [...workflows, ...knowledge]) {
147
+ sections.push(`## ${resource.path}\n\n${resource.content.trim()}`);
148
+ }
149
+ return sections.join('\n\n');
150
+ }
151
+
152
+ function parseManifest(value: unknown, context: ValidationContext): AgentManifest {
153
+ if (!isRecord(value)) throw new TypeError(`Agent ${context.source} agent.json must contain an object`);
154
+ const allowed = new Set([
155
+ 'name', 'version', 'description', 'trigger_rules', 'entry_points', 'tools', 'skills',
156
+ 'disallowed_tools', 'tags', 'role', 'context_mode', 'max_iterations', 'model',
157
+ 'provider', 'effort', 'memory', 'platforms', 'scopes', 'permissions',
158
+ ]);
159
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
160
+ if (unknown.length) throw new TypeError(`Agent ${context.source} agent.json has unknown fields: ${unknown.join(', ')}`);
161
+ if (!isRecord(value.trigger_rules)) {
162
+ throw new TypeError(`Agent ${context.source} trigger_rules must be an object`);
163
+ }
164
+ const triggerUnknown = Object.keys(value.trigger_rules)
165
+ .filter((key) => key !== 'file_patterns' && key !== 'keywords');
166
+ if (triggerUnknown.length) {
167
+ throw new TypeError(`Agent ${context.source} trigger_rules has unknown fields: ${triggerUnknown.join(', ')}`);
168
+ }
169
+ return value as unknown as AgentManifest;
170
+ }
171
+
172
+ function resources(value: unknown, field: string): readonly AgentResource[] {
173
+ if (!Array.isArray(value)) throw new TypeError(`Agent package ${field} must be an array`);
174
+ return Object.freeze(value.map((item) => {
175
+ if (!isRecord(item) || typeof item.path !== 'string' || typeof item.content !== 'string') {
176
+ throw new TypeError(`Agent package ${field} entries must contain path and content strings`);
177
+ }
178
+ return Object.freeze({ path: item.path, content: item.content });
179
+ }));
180
+ }
181
+
182
+ function stringList(value: unknown, field: string, required = false): readonly string[] {
183
+ if (!Array.isArray(value) || (required && value.length === 0)
184
+ || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
185
+ throw new TypeError(`Agent agent.json ${field} must contain non-empty strings`);
186
+ }
187
+ const normalized = value.map((entry) => String(entry).trim());
188
+ if (new Set(normalized).size !== normalized.length) {
189
+ throw new TypeError(`Agent agent.json ${field} must not contain duplicates`);
190
+ }
191
+ return Object.freeze(normalized);
192
+ }
193
+
194
+ function optionalStringList(value: unknown, field: string): readonly string[] | undefined {
195
+ return value === undefined ? undefined : stringList(value, field);
196
+ }
197
+
198
+ function combinedNames(
199
+ declared: unknown,
200
+ discovered: readonly string[] | undefined,
201
+ field: string,
202
+ ): readonly string[] | undefined {
203
+ const values = [...(optionalStringList(declared, field) ?? []), ...(discovered ?? [])];
204
+ return values.length ? Object.freeze([...new Set(values)]) : undefined;
205
+ }
206
+
207
+ function nonEmptyText(value: unknown, field: string): string {
208
+ if (typeof value !== 'string' || !value.trim()) {
209
+ throw new TypeError(`Agent agent.json ${field} must be a non-empty string`);
210
+ }
211
+ return value.trim();
212
+ }
213
+
214
+ function optionalText(value: unknown, field: string): string | undefined {
215
+ return value === undefined ? undefined : nonEmptyText(value, field);
216
+ }
217
+
218
+ function semanticVersion(value: unknown): string {
219
+ const version = nonEmptyText(value, 'version');
220
+ if (!VERSION.test(version)) throw new TypeError('Agent agent.json version must be semantic version');
221
+ return version;
222
+ }
223
+
224
+ function optionalPositiveInteger(value: unknown, field: string): number | undefined {
225
+ if (value === undefined) return undefined;
226
+ if (!Number.isInteger(value) || Number(value) < 1) {
227
+ throw new TypeError(`Agent agent.json ${field} must be a positive integer`);
228
+ }
229
+ return Number(value);
230
+ }
231
+
232
+ function optionalEnum<T extends string>(
233
+ value: unknown,
234
+ field: string,
235
+ allowed: readonly T[],
236
+ ): T | undefined {
237
+ if (value === undefined) return undefined;
238
+ if (typeof value !== 'string' || !allowed.includes(value as T)) {
239
+ throw new TypeError(`Agent agent.json ${field} must be one of: ${allowed.join(', ')}`);
240
+ }
241
+ return value as T;
242
+ }
243
+
244
+ function optionalScopes(value: unknown): readonly AgentScope[] | undefined {
245
+ const scopes = optionalStringList(value, 'scopes');
246
+ if (scopes?.some((scope) => scope !== 'private' && scope !== 'group' && scope !== 'channel')) {
247
+ throw new TypeError('Agent agent.json scopes must be private, group, or channel');
248
+ }
249
+ return scopes as readonly AgentScope[] | undefined;
250
+ }
251
+
252
+ function isRecord(value: unknown): value is Record<string, unknown> {
253
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
37
254
  }
package/src/provider.ts CHANGED
@@ -1,41 +1,86 @@
1
- import { join } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
2
  import { featureId } from '@zhin.js/plugin-runtime';
3
3
  import {
4
4
  defineFeatureProvider,
5
+ type DiscoveryHost,
5
6
  type SourceConvention,
6
7
  } from '@zhin.js/feature-kit';
7
8
  import { AgentIndex } from './agent-index.js';
8
- import { parseAgentMarkdown } from './definition.js';
9
+ import { parseAgentPackage, type AgentResource } from './definition.js';
9
10
 
10
11
  export const agentFeatureId = featureId('zhin.agent');
11
12
 
12
- const agentFiles: SourceConvention = {
13
- id: 'agent-markdown',
13
+ const agentPackages: SourceConvention = {
14
+ id: 'agent-package',
14
15
  async *discover(context) {
15
- const directory = join(context.packageRoot, 'agents');
16
- const entries = [...await context.host.list(directory)]
16
+ const root = join(context.packageRoot, 'agents');
17
+ const entries = [...await context.host.list(root)]
17
18
  .sort((left, right) => left.name.localeCompare(right.name));
18
19
  for (const entry of entries) {
19
- if (entry.kind !== 'file' || !isAgentFile(entry.name)) continue;
20
+ if (entry.kind !== 'directory' || !isAgentName(entry.name)) continue;
21
+ const directory = join(root, entry.name);
22
+ const children = await context.host.list(directory);
23
+ if (!children.some((child) => child.kind === 'file' && child.name === 'agent.json')) continue;
24
+ const relatedSources = [
25
+ ...children
26
+ .filter((child) => child.kind === 'file' && child.name !== 'agent.json')
27
+ .map((child) => join(directory, child.name)),
28
+ ...await discoverRelatedSources(context.host, directory),
29
+ ];
20
30
  yield {
21
- localName: entry.name.slice(0, -'.agent.md'.length),
22
- source: join(directory, entry.name),
31
+ localName: entry.name,
32
+ source: join(directory, 'agent.json'),
33
+ relatedSources: Object.freeze(relatedSources),
23
34
  target: 'server',
24
35
  };
25
36
  }
26
37
  },
27
- load(source, context) {
28
- return context.host.readText(source.source);
38
+ async load(source, context) {
39
+ const directory = dirname(source.source);
40
+ const manifest = JSON.parse(await context.host.readText(source.source)) as unknown;
41
+ const files = await readRootFiles(context.host, directory);
42
+ return {
43
+ manifest,
44
+ files,
45
+ workflows: await readResourceDirectory(context.host, directory, 'workflows'),
46
+ knowledge: await readResourceDirectory(context.host, directory, 'knowledge'),
47
+ privateToolNames: (await childDirectories(context.host, join(directory, 'tools')))
48
+ .map((name) => `agent__${source.localName}__${name}`),
49
+ privateSkillNames: (await childDirectories(context.host, join(directory, 'skills')))
50
+ .map((name) => `agent__${source.localName}__${name}`),
51
+ };
29
52
  },
30
53
  };
31
54
 
55
+ async function discoverRelatedSources(host: DiscoveryHost, directory: string): Promise<string[]> {
56
+ const result: string[] = [];
57
+ for (const name of ['workflows', 'tools', 'skills', 'knowledge'] as const) {
58
+ await collectFiles(host, join(directory, name), result);
59
+ }
60
+ return result;
61
+ }
62
+
63
+ async function collectFiles(host: DiscoveryHost, directory: string, result: string[]): Promise<void> {
64
+ for (const entry of await host.list(directory)) {
65
+ const target = join(directory, entry.name);
66
+ if (entry.kind === 'directory' && isAgentName(entry.name)) await collectFiles(host, target, result);
67
+ else if (entry.kind === 'file') result.push(target);
68
+ }
69
+ }
70
+
71
+ async function childDirectories(host: DiscoveryHost, directory: string): Promise<string[]> {
72
+ return [...await host.list(directory)]
73
+ .filter((entry) => entry.kind === 'directory' && isAgentName(entry.name))
74
+ .map((entry) => entry.name)
75
+ .sort();
76
+ }
77
+
32
78
  const agentFeature = defineFeatureProvider({
33
79
  protocol: 1,
34
80
  id: agentFeatureId,
35
81
  authoring: {
36
- setupMethod: 'addAgent',
37
- conventions: [agentFiles],
38
- validate: parseAgentMarkdown,
82
+ conventions: [agentPackages],
83
+ validate: parseAgentPackage,
39
84
  },
40
85
  runtime: {
41
86
  project(slots, context) {
@@ -44,8 +89,35 @@ const agentFeature = defineFeatureProvider({
44
89
  },
45
90
  });
46
91
 
47
- function isAgentFile(value: string): boolean {
48
- return /^[a-z0-9][a-z0-9-]*\.agent\.md$/u.test(value);
92
+ async function readRootFiles(host: DiscoveryHost, directory: string): Promise<Readonly<Record<string, string>>> {
93
+ const entries = await host.list(directory);
94
+ const files = await Promise.all(entries
95
+ .filter((entry) => entry.kind === 'file' && entry.name !== 'agent.json')
96
+ .map(async (entry) => [entry.name, await host.readText(join(directory, entry.name))] as const));
97
+ return Object.freeze(Object.fromEntries(files));
98
+ }
99
+
100
+ async function readResourceDirectory(
101
+ host: DiscoveryHost,
102
+ agentDirectory: string,
103
+ name: 'workflows' | 'knowledge',
104
+ ): Promise<readonly AgentResource[]> {
105
+ const directory = join(agentDirectory, name);
106
+ const entries = [...await host.list(directory)]
107
+ .filter((entry) => entry.kind === 'file' && isResourceFile(entry.name))
108
+ .sort((left, right) => left.name.localeCompare(right.name));
109
+ return Promise.all(entries.map(async (entry) => Object.freeze({
110
+ path: `${name}/${entry.name}`,
111
+ content: await host.readText(join(directory, entry.name)),
112
+ })));
113
+ }
114
+
115
+ function isAgentName(value: string): boolean {
116
+ return /^[a-z0-9][a-z0-9-]*$/u.test(value);
117
+ }
118
+
119
+ function isResourceFile(value: string): boolean {
120
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(?:md|txt|json|ya?ml|csv|sh)$/u.test(value);
49
121
  }
50
122
 
51
123
  export { agentFeature };