bazis 0.137.0 → 0.138.0
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 +7 -4
- package/cli/generateProject.ts +11 -18
- package/cli/main.ts +1 -1
- package/cli/templates/passport.ts +1 -1
- package/core/access/catalog/moduleFields.ts +25 -2
- package/core/access/testing/index.ts +26 -4
- package/core/agent/ToolRunner.ts +2 -1
- package/core/agent/defineTool.ts +109 -0
- package/core/agent/index.ts +8 -3
- package/core/agent/moduleContributions-v1.ts +3 -10
- package/core/app/index.ts +1 -1
- package/core/app/messages.ts +3 -3
- package/core/app/runApp.ts +90 -26
- package/core/cache/ICache.ts +1 -1
- package/core/cache/README.md +4 -2
- package/core/cache/SPEC.md +16 -9
- package/core/cache/http/missingCacheModuleCheck.ts +5 -4
- package/core/cache/internal/buildCacheProviders.ts +4 -5
- package/core/cache/providers/cachedProviders.ts +1 -1
- package/core/di/index.ts +0 -4
- package/core/di/lowLevel.ts +12 -0
- package/core/di/module/index.ts +0 -3
- package/core/di/module/shortcuts.ts +31 -86
- package/core/generatedRuntime.ts +11 -0
- package/core/grpc/Grpc.module.ts +2 -1
- package/core/grpc/grpcClients.ts +2 -1
- package/core/http/Authorization/metadata.ts +1 -1
- package/core/http/Errors/HttpError.ts +1 -1
- package/core/http/Http.config.ts +22 -0
- package/core/http/HttpContext/HttpContext.ts +1 -1
- package/core/http/HttpServer.ts +10 -7
- package/core/http/OpenApi/openApiDocument.ts +1 -1
- package/core/http/httpModule.ts +10 -8
- package/core/http/index.ts +1 -0
- package/core/http/options.ts +5 -1
- package/core/http-client/httpClients.ts +2 -1
- package/core/infra/Infra.ts +3 -3
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/codex/contracts.ts +3 -3
- package/core/infra/connectors/llm.ts +2 -1
- package/core/infra/connectors/redis.ts +2 -1
- package/core/kernel/KernelBuilder.ts +2 -1
- package/core/kernel/config/ConfigRegistry.ts +2 -1
- package/core/kernel/config/addConfigOptions.ts +2 -1
- package/core/kernel/config/defineConfig.ts +1 -1
- package/core/kernel/events/EventBus.ts +1 -0
- package/core/kernel/events/eventSubscribers.ts +1 -1
- package/core/kernel/events/onEvent.ts +2 -1
- package/core/orm/buildOrmProviders.ts +2 -1
- package/core/orm/encryptedWith.ts +1 -1
- package/core/orm/ormModule.ts +2 -1
- package/core/orm/registerRepositories.ts +2 -1
- package/core/scripts/di-generate.ts +52 -8
- package/core/testing/index.ts +51 -11
- package/core/tools/index.ts +1 -1
- package/core/tools/orm/ormEntityTools.ts +54 -28
- package/core/websocket/DESIGN.md +5 -3
- package/core/websocket/middleware.ts +1 -1
- package/core/websocket/websocketModule.ts +3 -3
- package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +1 -2
- package/core/workflow/definitions_module/code/defineProcess.ts +213 -11
- package/core/workflow/definitions_module/code/inspectProcesses.ts +12 -2
- package/core/workflow/index.ts +14 -1
- package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +10 -6
- package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +5 -2
- package/core/workflow/testing/index.ts +46 -4
- package/index.ts +4 -3
- package/library/terminal/messages.ts +6 -6
- package/library/validation/README.md +1 -1
- package/library/validation/SPEC.md +1 -1
- package/library/workflow/index.ts +1 -0
- package/library/workflow/nodeConfigs.ts +130 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -142,7 +142,8 @@ import { dbConfig } from "../config/db.config"; // defineConfig("db", { default:
|
|
|
142
142
|
export class AppInfra {}
|
|
143
143
|
|
|
144
144
|
// src/index.ts
|
|
145
|
-
|
|
145
|
+
@App({ modules: [AppModule], infra: AppInfra, http: { health: true } })
|
|
146
|
+
export class Application {}
|
|
146
147
|
```
|
|
147
148
|
|
|
148
149
|
A feature module attaches its own `DbContext` and entities with
|
|
@@ -180,11 +181,13 @@ import { CodexError, RU_CODEX_MESSAGES } from "bazis/core/infra";
|
|
|
180
181
|
import { RU_UI_LABELS } from "bazis/core/app";
|
|
181
182
|
|
|
182
183
|
CodexError.useMessages(RU_CODEX_MESSAGES); // Codex connector errors
|
|
183
|
-
|
|
184
|
+
@App({
|
|
185
|
+
modules: [AppModule],
|
|
184
186
|
language: "ru", // response texts: validation, 404/405, 401/403, binding errors
|
|
185
187
|
messages: { notFound: "Нет такой страницы" }, // optional: own texts for single keys
|
|
186
|
-
ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts
|
|
187
|
-
})
|
|
188
|
+
ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts,
|
|
189
|
+
})
|
|
190
|
+
export class Application {}
|
|
188
191
|
```
|
|
189
192
|
|
|
190
193
|
`language` is per application: it is applied when the response is written, so
|
package/cli/generateProject.ts
CHANGED
|
@@ -169,39 +169,32 @@ function buildProjectFiles(name: string, dependency: string, frameworkMode: Fram
|
|
|
169
169
|
["bazis.config.json", `${JSON.stringify(codegen, null, 2)}\n`],
|
|
170
170
|
// Bun reads .env, .env.local and .env.<NODE_ENV>: none of them belongs in git.
|
|
171
171
|
[".gitignore", "node_modules/\nbin/\nsrc/generated/\n.env\n.env.*\n!.env.example\n"],
|
|
172
|
-
[".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\
|
|
172
|
+
[".env.example", "# Copy to .env (Bun loads it automatically).\nBAZIS_ENV=development\nBAZIS_HTTP__HOST=127.0.0.1\nBAZIS_HTTP__PORT=3000\n"],
|
|
173
173
|
["src/app/test/health.test.ts", HEALTH_TEST],
|
|
174
174
|
["AGENTS.md", "# Working on this bazis project\n\nBefore changing the application, read the [module architecture](docs/architecture/MODULE_ARCHITECTURE.md). Create new modules only with `bunx bazis g module` or `bunx bazis g pack`; fill in the generated `MODULE.md` afterwards. Only codegen updates `src/generated/`.\n"],
|
|
175
|
-
["docs/architecture/MODULE_ARCHITECTURE.md", "# Application module architecture\n\n`src/index.ts` calls `runApp`; `src/app/modules/App.module.ts` composes feature modules through `imports`. The application root owns no domain logic.\n\nOne self-contained function is an atomic module. It owns its data, services, HTTP and background handlers. A composite module is only for several independent functions; its root does composition. Layers and file counts alone do not create submodules.\n\nCreate new modules only with `bunx bazis g module <Name> --empty|--minimal|--full` or `bunx bazis g pack <Name> --parts <a,b>`; add a part to an existing composite with `bunx bazis g module <Name> --pack <Pack>`. The generated `MODULE.md` records the creation command. Before implementing, define the responsibility and public entries, then fill in the generated `MODULE.md`: fields, errors, dependencies, exports and checks. Use the public APIs of the `bazis` package and its DI and ORM. Do not edit `src/generated/` by hand; run `bunx bazis codegen`.\n\n`--minimal` creates a sample CRUD with the ORM. To run it the application needs a database provider and a ready schema. For a first function without a database use `--empty`. Check types and the binary build after changes that affect startup.\n"],
|
|
175
|
+
["docs/architecture/MODULE_ARCHITECTURE.md", "# Application module architecture\n\n`src/index.ts` calls `runApp(Application)`; `src/app/App.ts` declares the application with `@App`: its modules, infrastructure and servers; `src/app/modules/App.module.ts` composes feature modules through `imports`. The application root owns no domain logic.\n\nOne self-contained function is an atomic module. It owns its data, services, HTTP and background handlers. A composite module is only for several independent functions; its root does composition. Layers and file counts alone do not create submodules.\n\nCreate new modules only with `bunx bazis g module <Name> --empty|--minimal|--full` or `bunx bazis g pack <Name> --parts <a,b>`; add a part to an existing composite with `bunx bazis g module <Name> --pack <Pack>`. The generated `MODULE.md` records the creation command. Before implementing, define the responsibility and public entries, then fill in the generated `MODULE.md`: fields, errors, dependencies, exports and checks. Use the public APIs of the `bazis` package and its DI and ORM. Do not edit `src/generated/` by hand; run `bunx bazis codegen`.\n\n`--minimal` creates a sample CRUD with the ORM. To run it the application needs a database provider and a ready schema. For a first function without a database use `--empty`. Check types and the binary build after changes that affect startup.\n"],
|
|
176
176
|
["src/app/modules/App.module.ts", 'import { Module } from "bazis/core/di";\n\n@Module({ imports: [], exports: [] })\nexport class AppModule {}\n'],
|
|
177
|
-
["src/
|
|
177
|
+
["src/app/App.ts", 'import { App } from "bazis/core/app";\nimport { AppModule } from "./modules/App.module";\n\n/** The application: its modules and the servers it starts. `src/index.ts` runs it, tests start the same one. */\n@App({\n modules: [AppModule],\n http: { health: true },\n})\nexport class Application {}\n'],
|
|
178
|
+
["src/index.ts", 'import { runApp } from "bazis/core/app";\nimport { Application } from "./app/App";\nimport { registerBazisGeneratedRuntime } from "./generated/bazis/runtime";\n\nawait registerBazisGeneratedRuntime();\nawait runApp(Application);\n'],
|
|
178
179
|
["README.md", `# ${name}\n\nA [bazis](https://www.npmjs.com/package/bazis) application. ${frameworkMode === "registry"
|
|
179
180
|
? "The framework comes from npm as the `bazis` dependency; update it with `bun update bazis`."
|
|
180
181
|
: frameworkMode === "snapshot"
|
|
181
182
|
? "The framework package is copied to `vendor/bazis`; keep it in Git and move it with the project. The framework checkout is no longer needed. Framework updates do not reach this copy automatically."
|
|
182
|
-
: "The `bazis` dependency links an external local checkout (`--link-framework`). Moving the project needs the same package and an updated path in `package.json`."}\nRead the [local architecture](docs/architecture/MODULE_ARCHITECTURE.md) before changing modules.\n\nNeeds Bun ≥ 1.4.0. Settings go to \`.env\` (example: \`.env.example\`).\n\n\`\`\`sh\nbun install\nbunx bazis dev\n# GET http://127.0.0.1:3000/health\n# If the port is busy:
|
|
183
|
+
: "The `bazis` dependency links an external local checkout (`--link-framework`). Moving the project needs the same package and an updated path in `package.json`."}\nRead the [local architecture](docs/architecture/MODULE_ARCHITECTURE.md) before changing modules.\n\nNeeds Bun ≥ 1.4.0. Settings go to \`.env\` (example: \`.env.example\`).\n\n\`\`\`sh\nbun install\nbunx bazis dev\n# GET http://127.0.0.1:3000/health\n# If the port is busy: BAZIS_HTTP__PORT=3100 bunx bazis dev\n\`\`\`\n\nAdd an atomic module from the project root: \`bunx bazis g module Task --empty\`.\nAfter filling in its passport and implementation, run \`bunx bazis codegen\`.\nTests: \`bunx bazis test\`. Typecheck: \`bunx bazis build\`. Binary: \`bunx bazis build --bin\`, run it with \`bun run start\`.\n\n\`--minimal\` creates a sample CRUD with paging (20 records by default, at most 100 over HTTP); it needs a database provider and a schema to run. The service returns \`PageResult\`; the HTTP controller builds JSON:API. \`DbContext.saveChanges()\` saves every change of its context. In ORM predicates combine conditions with \`.and()\` and \`.or()\`; codegen rejects \`&&\` and \`||\` between predicates.\n`],
|
|
183
184
|
];
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
const HEALTH_TEST = `import { expect, test } from "bun:test";
|
|
188
|
+
import { startTestApp } from "bazis/core/testing";
|
|
189
|
+
import { Application } from "../App";
|
|
187
190
|
|
|
188
|
-
// Starts the
|
|
191
|
+
// Starts the application declared in src/app/App.ts on a free port and checks /health.
|
|
189
192
|
test("app starts and answers /health", async () => {
|
|
190
|
-
|
|
191
|
-
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
|
|
192
|
-
const port = String(probe.port);
|
|
193
|
-
probe.stop(true);
|
|
194
|
-
const app = Bun.spawn([process.execPath, "run", "src/index.ts"], { env: { ...process.env, HOST: "127.0.0.1", PORT: port }, stdout: "ignore", stderr: "inherit" });
|
|
193
|
+
const app = await startTestApp(Application);
|
|
195
194
|
try {
|
|
196
|
-
|
|
197
|
-
for (let attempt = 0; attempt < 100 && status !== 200; attempt++) {
|
|
198
|
-
await Bun.sleep(100);
|
|
199
|
-
status = await fetch(\`http://127.0.0.1:\${port}/health\`).then((response) => response.status, () => 0);
|
|
200
|
-
}
|
|
201
|
-
expect(status).toBe(200);
|
|
195
|
+
expect((await app.fetch("/health")).status).toBe(200);
|
|
202
196
|
} finally {
|
|
203
|
-
app.
|
|
204
|
-
await app.exited;
|
|
197
|
+
await app.stop();
|
|
205
198
|
}
|
|
206
199
|
});
|
|
207
200
|
`;
|
package/cli/main.ts
CHANGED
|
@@ -48,7 +48,7 @@ Generation options:
|
|
|
48
48
|
--minimal Compact example CRUD and MODULE.md (default)
|
|
49
49
|
--full Example CRUD/list/cache/auth/background/AI and MODULE.md;
|
|
50
50
|
uses src/app/modules/auth helpers when the project has them.
|
|
51
|
-
To run, the host needs a cache (
|
|
51
|
+
To run, the host needs a cache (@App({ cache: memory() }))
|
|
52
52
|
and a database provider. Legacy alias: --enterprise
|
|
53
53
|
--parts <a,b,...> Independent empty atomic parts; pack only, at least two
|
|
54
54
|
--dry-run Validate and list planned changes without writing
|
|
@@ -76,7 +76,7 @@ exports: [I${m}Service]. TypeScript contract: services/I${e}.service.ts;
|
|
|
76
76
|
HTTP access is defined by the controller and the host policy, separately from DI exports.
|
|
77
77
|
I${m}Service → ${m}Service, scoped lifetime. The context and DbSet belong to the ORM.
|
|
78
78
|
${profile === "full" ? `${m}SummaryTool is listed in tools: [${m}SummaryTool]; the framework registers it as scoped, the tool is not exported.
|
|
79
|
-
The service is scoped; its keyed @Cacheable works when the host installs the cache (
|
|
79
|
+
The service is scoped; its keyed @Cacheable works when the host installs the cache (@App cache: memory()).
|
|
80
80
|
The background service gets its own scope per tick and disposes it in finally.
|
|
81
81
|
The host also provides auth from src/app/modules/auth (TokenKind.Admin/Client)
|
|
82
82
|
and an LLM connector, which registers AgentRuntime. The CLI does not create them.
|
|
@@ -26,6 +26,8 @@ const REFERENCE_CHANNEL = createModuleOwnedMetadataChannel<ReferencePayload>("ac
|
|
|
26
26
|
const ROLE_CHANNEL = createModuleOwnedMetadataChannel<RolePayload>("access.role/v1");
|
|
27
27
|
/** The classes of `references`: the field registers them, like `tools` and `controllers`. */
|
|
28
28
|
const REFERENCE_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly kind: string }, object>("access.reference-class/v1");
|
|
29
|
+
/** The `lookup` classes of `permissions`: the field registers them too. */
|
|
30
|
+
const LOOKUP_CLASS_CHANNEL = createModuleOwnedProviderChannel<{ readonly permission: string }, object>("access.lookup-class/v1");
|
|
29
31
|
|
|
30
32
|
/** The values of `in`/`notIn` a role may list; the run-time limit is `access.maxInList`. */
|
|
31
33
|
const STARTUP_MAX_IN_LIST = 1000;
|
|
@@ -46,7 +48,20 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
|
|
|
46
48
|
if (!isDeclaredPermissions(fields.permissions)) throw new AccessSetupError(`${module}: permissions must be a definePermissions(...) result.`);
|
|
47
49
|
const problems = Object.entries(fields.permissions).flatMap(([key, spec]) => permissionProblems(key, spec));
|
|
48
50
|
if (problems.length > 0) throw setupError(module, "permissions", problems);
|
|
49
|
-
|
|
51
|
+
const lookups = new Set<unknown>();
|
|
52
|
+
for (const [key, spec] of Object.entries(fields.permissions)) {
|
|
53
|
+
context.addMetadata(PERMISSION_CHANNEL, { key, spec });
|
|
54
|
+
for (const lookup of lookupsOf(spec)) {
|
|
55
|
+
// A lookup another module owns and exports is taken from it.
|
|
56
|
+
if (lookups.has(lookup) || importsExport(metadata, lookup)) continue;
|
|
57
|
+
lookups.add(lookup);
|
|
58
|
+
try {
|
|
59
|
+
context.registerScoped(LOOKUP_CLASS_CHANNEL, scoped(lookup as Class<object>), { permission: key });
|
|
60
|
+
} catch {
|
|
61
|
+
// The module registered the class its own way: that registration stands.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
50
65
|
}
|
|
51
66
|
if (fields.references !== undefined) {
|
|
52
67
|
const references = fields.references;
|
|
@@ -117,7 +132,7 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
117
132
|
}
|
|
118
133
|
}
|
|
119
134
|
for (const lookup of lookupsOf(payload.spec)) {
|
|
120
|
-
if (snapshot.countProviders(lookup) === 0) throw new AccessSetupError(`${ownerName}: ${lookup.name} of permission "${payload.key}" is not registered
|
|
135
|
+
if (snapshot.countProviders(lookup) === 0) throw new AccessSetupError(`${ownerName}: ${lookup.name} of permission "${payload.key}" is not registered.`);
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
138
|
|
|
@@ -136,6 +151,14 @@ registerModuleOwnedContributionValidator((snapshot) => {
|
|
|
136
151
|
}
|
|
137
152
|
});
|
|
138
153
|
|
|
154
|
+
/** Whether a module the given one imports exports this class. */
|
|
155
|
+
function importsExport(metadata: BazisModuleMetadata, type: unknown): boolean {
|
|
156
|
+
return (metadata.imports ?? []).some((imported) => {
|
|
157
|
+
const exported = (imported as { readonly exports?: unknown }).exports;
|
|
158
|
+
return Array.isArray(exported) && exported.includes(type);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
/** What the modules of the application's container declared. */
|
|
140
163
|
export function declarationsOf(services: ServiceProvider): { permissions: OwnedPermission[]; kinds: OwnedReferenceKind[]; roles: DeclaredRole[] } {
|
|
141
164
|
if (!(services instanceof DiContainer)) throw new AccessSetupError("Access declarations require the framework DiContainer.");
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
* Test helpers of access (ACCESS-001 §8): a rights system whose grants are set
|
|
3
3
|
* right in the test.
|
|
4
4
|
*/
|
|
5
|
+
import { singletonValue, type ProviderDefinition } from "../../di";
|
|
5
6
|
import { PRINCIPAL_STATE_KEY, type AuthorizeContext, type RequestPrincipal } from "../../http";
|
|
6
7
|
import { AccessPolicy, type ScopePrincipals } from "../contracts/AccessPolicy";
|
|
7
8
|
import type { AccessSubject, Grants, Scope } from "../contracts/types";
|
|
8
9
|
|
|
9
10
|
export interface StaticAccessOptions {
|
|
10
|
-
/** Subject → permission → grants; a permission not listed gives
|
|
11
|
-
readonly grants
|
|
11
|
+
/** Subject → permission → grants; a permission not listed gives {@link otherwise}. */
|
|
12
|
+
readonly grants?: Readonly<Record<string, Readonly<Record<string, Grants>>>>;
|
|
13
|
+
/** What a permission not listed in `grants` gives: nothing by default, `"all"` to allow everything. */
|
|
14
|
+
readonly otherwise?: Grants;
|
|
15
|
+
/** Who a request without a signed-in principal is; by default nobody, and the request is refused. */
|
|
16
|
+
readonly anonymous?: string;
|
|
12
17
|
/** Subject → the owner ids meaning it; by default `sub` after the last `:`. */
|
|
13
18
|
readonly owners?: Readonly<Record<string, readonly string[]>>;
|
|
14
19
|
/** Subject → its addressees in every partition; by default `user:<owner id>`. */
|
|
@@ -32,11 +37,12 @@ export class StaticAccessPolicy extends AccessPolicy {
|
|
|
32
37
|
|
|
33
38
|
subject(ctx: AuthorizeContext): AccessSubject | undefined {
|
|
34
39
|
const sub = (ctx.state.get(PRINCIPAL_STATE_KEY) as RequestPrincipal | undefined)?.subject;
|
|
35
|
-
|
|
40
|
+
if (sub !== undefined && sub !== "") return { sub };
|
|
41
|
+
return this.options.anonymous === undefined ? undefined : { sub: this.options.anonymous };
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
grants(subject: AccessSubject, permission: string): Grants {
|
|
39
|
-
return this.options.grants[subject.sub]?.[permission] ?? [];
|
|
45
|
+
return this.options.grants?.[subject.sub]?.[permission] ?? this.options.otherwise ?? [];
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
ownerIds(subject: AccessSubject): readonly string[] {
|
|
@@ -49,6 +55,22 @@ export class StaticAccessPolicy extends AccessPolicy {
|
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A rights system that allows everything, for a test that is not about rights:
|
|
60
|
+
* every subject has every permission in every partition.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* const app = await startTestApp(OrdersApp, { overrides: [allowAll()] });
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* Requests still need a signed-in principal; `allowAll({ anonymous: "test" })`
|
|
67
|
+
* also lets in a request without one, as the subject `test`. `grants` narrows
|
|
68
|
+
* single subjects: `allowAll({ grants: { "user:2": { "orders.delete": [] } } })`.
|
|
69
|
+
*/
|
|
70
|
+
export function allowAll(options: Omit<StaticAccessOptions, "otherwise"> = {}): ProviderDefinition {
|
|
71
|
+
return singletonValue(AccessPolicy, new StaticAccessPolicy({ ...options, otherwise: "all" }));
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
function ownerId(sub: string): string {
|
|
53
75
|
return sub.slice(sub.lastIndexOf(":") + 1);
|
|
54
76
|
}
|
package/core/agent/ToolRunner.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DiError, Global, HOSTED_SERVICE, Module, registerModuleMetadataExpander, SERVICE_PROVIDER, type BazisModuleMetadata, type ServiceProvider } from "../di";
|
|
2
|
+
import { DI } from "../di/lowLevel";
|
|
2
3
|
import { LOGGER } from "../kernel/logging/Logger";
|
|
3
4
|
import { modelValidatorAdapter } from "../../library/validation";
|
|
4
5
|
import { AgentRegistry, type ToolDefinition } from "./AgentRegistry";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ServiceProvider, type Class } from "../di";
|
|
2
|
+
import { registerFrameworkClassDeps } from "../di/module/autoDeps";
|
|
3
|
+
import { Tool } from "./decorators/Tool";
|
|
4
|
+
import { AgentSetupError } from "./errors";
|
|
5
|
+
import { toolMetadataOf, type ToolOptions } from "./metadata";
|
|
6
|
+
import type { AgentToolExecutionContext } from "./AgentToolExecutor";
|
|
7
|
+
|
|
8
|
+
declare const TOOL_TYPES: unique symbol;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Tool made by {@link defineTool}. It stands in `@Module({ tools })` next to
|
|
12
|
+
* the classes with `@Tool`, and a process names it by reference.
|
|
13
|
+
*/
|
|
14
|
+
export type DefinedTool<I = unknown, O = unknown> = Class<object> & {
|
|
15
|
+
/** The name it is called by: `orm.order.find`. */
|
|
16
|
+
readonly toolName: string;
|
|
17
|
+
/** Only for the types: what `execute` takes and gives back. */
|
|
18
|
+
readonly [TOOL_TYPES]?: { readonly input: I; readonly output: O };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Several made Tools under the names their factory gave them: `orderTools.find`. */
|
|
22
|
+
export type ToolSet<K extends string = string> = { readonly [P in K]: DefinedTool };
|
|
23
|
+
|
|
24
|
+
/** What `defineTool` takes: what `@Tool` takes, without the classes of input and output, and the work itself. */
|
|
25
|
+
export interface DefinedToolOptions<I = unknown, O = unknown> extends Omit<ToolOptions, "input" | "output"> {
|
|
26
|
+
/**
|
|
27
|
+
* Does the work. `services` is the scope of the call: `services.resolve(OrdersDbContext)`.
|
|
28
|
+
* The input comes as the caller sent it: check it here.
|
|
29
|
+
*/
|
|
30
|
+
execute(input: I, context: AgentToolExecutionContext, services: ServiceProvider): O | Promise<O>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFINED = new WeakSet<object>();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A Tool without a class: for Tools made by a function, when their number and
|
|
37
|
+
* names are known only from its arguments. A Tool written by hand is a class
|
|
38
|
+
* with `@Tool`.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* export function exportTools(formats: readonly string[]): ToolSet {
|
|
42
|
+
* return Object.fromEntries(formats.map((format) => [format, defineTool({
|
|
43
|
+
* name: `reports.export.${format}`,
|
|
44
|
+
* description: `Exports a report as ${format}.`,
|
|
45
|
+
* sideEffect: "read",
|
|
46
|
+
* callers: ["workflow"],
|
|
47
|
+
* inputSchema: { type: "object", properties: { reportId: { type: "string" } }, required: ["reportId"] },
|
|
48
|
+
* execute: (input: { reportId: string }, _context, services) => services.resolve(ReportService).export(input.reportId, format),
|
|
49
|
+
* })]));
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* @Module({ tools: [SendReportTool, exportTools(["pdf", "csv"])] })
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function defineTool<I = unknown, O = unknown>(definition: DefinedToolOptions<I, O>): DefinedTool<I, O> {
|
|
56
|
+
if (definition === null || typeof definition !== "object" || typeof definition.execute !== "function") {
|
|
57
|
+
throw new AgentSetupError("defineTool takes the description of a Tool with its execute(input, context, services).");
|
|
58
|
+
}
|
|
59
|
+
const { execute, ...options } = definition;
|
|
60
|
+
|
|
61
|
+
@Tool(options)
|
|
62
|
+
class MadeTool {
|
|
63
|
+
static readonly toolName = String(options.name).trim();
|
|
64
|
+
|
|
65
|
+
constructor(private readonly services: ServiceProvider) {}
|
|
66
|
+
|
|
67
|
+
execute(input: unknown, context: AgentToolExecutionContext): unknown {
|
|
68
|
+
return execute(input as I, context, this.services);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The class is made here, so codegen never sees its constructor.
|
|
73
|
+
registerFrameworkClassDeps([[MadeTool, [ServiceProvider]]]);
|
|
74
|
+
DEFINED.add(MadeTool);
|
|
75
|
+
Object.defineProperty(MadeTool, "name", { value: `Tool(${MadeTool.toolName})` });
|
|
76
|
+
return MadeTool as unknown as DefinedTool<I, O>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a value came from {@link defineTool}. */
|
|
80
|
+
export function isDefinedTool(value: unknown): value is DefinedTool {
|
|
81
|
+
return typeof value === "function" && DEFINED.has(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @internal The Tools a module lists in `tools`: classes with `@Tool`, made Tools
|
|
86
|
+
* and sets of them, as one list of classes.
|
|
87
|
+
*/
|
|
88
|
+
export function moduleToolsOf(value: unknown): readonly Class<object>[] {
|
|
89
|
+
if (value === undefined) return [];
|
|
90
|
+
if (!Array.isArray(value)) throw new AgentSetupError("tools must be an array of classes with @Tool, Tools made by defineTool and sets of them.");
|
|
91
|
+
const out: Class<object>[] = [];
|
|
92
|
+
for (const entry of value) {
|
|
93
|
+
if (typeof entry === "function") {
|
|
94
|
+
out.push(entry as Class<object>);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const members = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? Object.entries(entry) : undefined;
|
|
98
|
+
if (members === undefined || members.length === 0 || members.some(([, member]) => !isDefinedTool(member))) {
|
|
99
|
+
throw new AgentSetupError("tools must list classes with @Tool, Tools made by defineTool and sets of them: an object in the list must hold only Tools made by defineTool.");
|
|
100
|
+
}
|
|
101
|
+
for (const [, member] of members) out.push(member as Class<object>);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The name a class with `@Tool` or a made Tool is called by; `undefined` for anything else. */
|
|
107
|
+
export function toolNameOf(tool: unknown): string | undefined {
|
|
108
|
+
return typeof tool === "function" ? toolMetadataOf(tool as Class<object>)?.name : undefined;
|
|
109
|
+
}
|
package/core/agent/index.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { Class } from "../di";
|
|
2
2
|
import { registerModuleDescriber } from "../kernel/report/moduleTree";
|
|
3
|
+
import { moduleToolsOf, type DefinedTool, type ToolSet } from "./defineTool";
|
|
3
4
|
import { toolMetadataOf } from "./metadata";
|
|
4
5
|
|
|
5
6
|
// The module tree of the startup screen: `orders.summary · read`.
|
|
6
|
-
registerModuleDescriber((metadata) => (metadata.tools
|
|
7
|
+
registerModuleDescriber((metadata) => moduleToolsOf(metadata.tools).map((tool) => {
|
|
7
8
|
const meta = toolMetadataOf(tool);
|
|
8
9
|
return { kind: "tool" as const, text: meta === undefined ? tool.name : `${meta.name} · ${meta.sideEffect}` };
|
|
9
10
|
}));
|
|
10
11
|
|
|
11
12
|
export { Tool } from "./decorators/Tool";
|
|
13
|
+
export { defineTool, isDefinedTool, toolNameOf, type DefinedTool, type DefinedToolOptions, type ToolSet } from "./defineTool";
|
|
12
14
|
export { describeTool } from "./Tool.contract";
|
|
13
15
|
export type {
|
|
14
16
|
AgentToolHookKindV1, AgentToolHookRefV1, AgentToolRequiredHookRefV1, AgentToolHookRegistrationBaseV1,
|
|
@@ -149,8 +151,11 @@ export {
|
|
|
149
151
|
// depend on program file order and silently stop merging in some projects.
|
|
150
152
|
declare module "../di/module/types/BazisModule" {
|
|
151
153
|
interface BazisModuleMetadata {
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
+
/**
|
|
155
|
+
* The Tools of the module: classes with `@Tool`, Tools made by `defineTool`
|
|
156
|
+
* and sets of them (`ormEntityTools(...)`). An agent definition names them in `toolNames`.
|
|
157
|
+
*/
|
|
158
|
+
readonly tools?: readonly (Class<object> | DefinedTool | ToolSet)[];
|
|
154
159
|
readonly agentToolHooks?: readonly import("./AgentToolHooks").AgentToolHookRegistrationV1[];
|
|
155
160
|
}
|
|
156
161
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type BazisModuleRef,
|
|
12
12
|
} from "../di";
|
|
13
13
|
import { AgentSetupError } from "./errors";
|
|
14
|
+
import { moduleToolsOf } from "./defineTool";
|
|
14
15
|
import { toolMetadataOf } from "./metadata";
|
|
15
16
|
import type { AgentToolHookRegistrationV1 } from "./AgentToolHooks";
|
|
16
17
|
|
|
@@ -25,18 +26,10 @@ const HOOK_CHANNEL = createModuleOwnedProviderChannel<AgentOwnedHookContribution
|
|
|
25
26
|
// Internal imports (including generatedRuntime's type-only AgentRegistry import)
|
|
26
27
|
// must not depend on the public barrel having augmented DI's metadata interface.
|
|
27
28
|
interface AgentModuleFields {
|
|
28
|
-
readonly tools?: readonly
|
|
29
|
+
readonly tools?: readonly unknown[];
|
|
29
30
|
readonly agentToolHooks?: readonly AgentToolHookRegistrationV1[];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function classValues(value: unknown, field: string): readonly Class<object>[] {
|
|
33
|
-
if (value === undefined) return [];
|
|
34
|
-
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "function")) {
|
|
35
|
-
throw new AgentSetupError(`${field} must be an array of classes.`);
|
|
36
|
-
}
|
|
37
|
-
return value as readonly Class<object>[];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
33
|
export function assertAgentToolHookIdV1(id: unknown): asserts id is string {
|
|
41
34
|
if (typeof id !== "string" || !/^[\x20-\x7e]{1,128}$/.test(id)) {
|
|
42
35
|
throw new AgentSetupError("Agent Tool hook id must contain 1 to 128 printable ASCII characters.");
|
|
@@ -59,7 +52,7 @@ function assertHook(registration: AgentToolHookRegistrationV1): void {
|
|
|
59
52
|
registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context) => {
|
|
60
53
|
const owner = metadata as BazisModuleRef;
|
|
61
54
|
const fields = metadata as BazisModuleMetadata & AgentModuleFields;
|
|
62
|
-
for (const target of
|
|
55
|
+
for (const target of moduleToolsOf(fields.tools)) {
|
|
63
56
|
const tool = toolMetadataOf(target);
|
|
64
57
|
if (!tool) throw new AgentSetupError(`${target.name || "<anonymous class>"} is listed as a tool but is not decorated with @Tool(...).`);
|
|
65
58
|
if (tool.callers.length === 0) {
|
package/core/app/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Composition layer: the single application entry point. Depends on the kernel, HTTP, gRPC and
|
|
2
2
|
// the validation library (it is the composition root, so it may use everything), hiding
|
|
3
3
|
// infrastructure wiring from application code.
|
|
4
|
-
export { runApp, type
|
|
4
|
+
export { App, runApp, type AppClass, type AppOptions, type AppSettings } from "./runApp";
|
|
5
5
|
export type { AppLanguage, AppMessages } from "./messages";
|
|
6
6
|
export * from "./ui";
|
package/core/app/messages.ts
CHANGED
|
@@ -35,7 +35,7 @@ const CATALOGS: Readonly<Record<AppLanguage, Readonly<Record<string, string>>>>
|
|
|
35
35
|
*/
|
|
36
36
|
export function createLocalize(language: AppLanguage = "en", messages: AppMessages = {}): Localize {
|
|
37
37
|
if (!Object.prototype.hasOwnProperty.call(CATALOGS, language)) {
|
|
38
|
-
throw new KernelError(
|
|
38
|
+
throw new KernelError(`@App: language "${String(language)}" is not supported. Use one of: ${Object.keys(CATALOGS).join(", ")}.`);
|
|
39
39
|
}
|
|
40
40
|
const catalog = CATALOGS[language];
|
|
41
41
|
const own = messages as Readonly<Record<string, string | undefined>>;
|
|
@@ -62,9 +62,9 @@ export function createLanguages(option: AppLanguage | AppLanguageOptions = "en",
|
|
|
62
62
|
return { negotiated: false, select: () => single };
|
|
63
63
|
}
|
|
64
64
|
const supported = [...new Set(option.supported)];
|
|
65
|
-
if (supported.length === 0) throw new KernelError("
|
|
65
|
+
if (supported.length === 0) throw new KernelError("@App: language.supported needs at least one language.");
|
|
66
66
|
if (!supported.includes(option.default)) {
|
|
67
|
-
throw new KernelError(
|
|
67
|
+
throw new KernelError(`@App: language.default "${String(option.default)}" must be one of language.supported (${supported.join(", ")}).`);
|
|
68
68
|
}
|
|
69
69
|
const selected = new Map<string, SelectedLanguage>(supported.map((language) => [language, { language, localize: createLocalize(language, messages) }]));
|
|
70
70
|
const fallback = selected.get(option.default)!;
|