bazis 0.137.0 → 0.139.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.
Files changed (90) hide show
  1. package/README.md +19 -8
  2. package/cli/generateProject.ts +11 -18
  3. package/cli/main.ts +1 -1
  4. package/cli/templates/passport.ts +1 -1
  5. package/core/access/catalog/moduleFields.ts +25 -2
  6. package/core/access/testing/index.ts +26 -4
  7. package/core/agent/AgentContextBuilder.ts +119 -16
  8. package/core/agent/AgentRuntime.ts +8 -0
  9. package/core/agent/AgentToolExecutor.ts +7 -0
  10. package/core/agent/ModelProvider.ts +6 -0
  11. package/core/agent/README.md +35 -3
  12. package/core/agent/ToolRunner.ts +2 -1
  13. package/core/agent/defineTool.ts +109 -0
  14. package/core/agent/errors.ts +32 -0
  15. package/core/agent/index.ts +15 -3
  16. package/core/agent/internal/AgentExecutionDriver.ts +598 -24
  17. package/core/agent/internal/AgentExecutionState.ts +26 -3
  18. package/core/agent/moduleContributions-v1.ts +3 -10
  19. package/core/agent/semantic.ts +60 -1
  20. package/core/app/index.ts +1 -1
  21. package/core/app/messages.ts +3 -3
  22. package/core/app/runApp.ts +90 -26
  23. package/core/cache/ICache.ts +1 -1
  24. package/core/cache/README.md +4 -2
  25. package/core/cache/SPEC.md +19 -12
  26. package/core/cache/http/missingCacheModuleCheck.ts +5 -4
  27. package/core/cache/internal/buildCacheProviders.ts +4 -5
  28. package/core/cache/providers/cachedProviders.ts +1 -1
  29. package/core/di/README.md +10 -3
  30. package/core/di/index.ts +0 -4
  31. package/core/di/lowLevel.ts +12 -0
  32. package/core/di/module/index.ts +0 -3
  33. package/core/di/module/shortcuts.ts +31 -86
  34. package/core/generatedRuntime.ts +11 -0
  35. package/core/grpc/Grpc.module.ts +2 -1
  36. package/core/grpc/grpcClients.ts +2 -1
  37. package/core/http/Authorization/metadata.ts +1 -1
  38. package/core/http/Errors/HttpError.ts +1 -1
  39. package/core/http/Http.config.ts +22 -0
  40. package/core/http/HttpContext/HttpContext.ts +1 -1
  41. package/core/http/HttpServer.ts +10 -7
  42. package/core/http/OpenApi/openApiDocument.ts +1 -1
  43. package/core/http/httpModule.ts +10 -8
  44. package/core/http/index.ts +1 -0
  45. package/core/http/options.ts +5 -1
  46. package/core/http-client/httpClients.ts +2 -1
  47. package/core/infra/Infra.ts +3 -3
  48. package/core/infra/anthropic.ts +11 -0
  49. package/core/infra/connectors/anthropic.ts +440 -0
  50. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  51. package/core/infra/connectors/codex/contracts.ts +3 -3
  52. package/core/infra/connectors/llm.ts +14 -1
  53. package/core/infra/connectors/llmRouter.ts +12 -0
  54. package/core/infra/connectors/openaiCompatible.ts +128 -63
  55. package/core/infra/connectors/openaiTextStream.ts +5 -2
  56. package/core/infra/connectors/redis.ts +2 -1
  57. package/core/infra/connectors/toolNames.ts +49 -0
  58. package/core/kernel/KernelBuilder.ts +2 -1
  59. package/core/kernel/config/ConfigRegistry.ts +2 -1
  60. package/core/kernel/config/addConfigOptions.ts +2 -1
  61. package/core/kernel/config/defineConfig.ts +1 -1
  62. package/core/kernel/events/EventBus.ts +1 -0
  63. package/core/kernel/events/eventSubscribers.ts +1 -1
  64. package/core/kernel/events/onEvent.ts +2 -1
  65. package/core/orm/buildOrmProviders.ts +2 -1
  66. package/core/orm/encryptedWith.ts +1 -1
  67. package/core/orm/ormModule.ts +2 -1
  68. package/core/orm/registerRepositories.ts +2 -1
  69. package/core/scripts/di-generate.ts +52 -8
  70. package/core/testing/index.ts +51 -11
  71. package/core/tools/index.ts +1 -1
  72. package/core/tools/orm/ormEntityTools.ts +54 -28
  73. package/core/websocket/DESIGN.md +7 -5
  74. package/core/websocket/middleware.ts +1 -1
  75. package/core/websocket/websocketModule.ts +3 -3
  76. package/core/workflow/definitions_module/WorkflowDefinitions.module.ts +1 -2
  77. package/core/workflow/definitions_module/code/defineProcess.ts +213 -11
  78. package/core/workflow/definitions_module/code/inspectProcesses.ts +12 -2
  79. package/core/workflow/index.ts +14 -1
  80. package/core/workflow/runtime_module/services/IWorkflowRuntime.service.ts +10 -6
  81. package/core/workflow/runtime_module/services/WorkflowRuntime.service.ts +5 -2
  82. package/core/workflow/testing/index.ts +46 -4
  83. package/index.ts +9 -3
  84. package/library/orm/Repository/README.md +14 -11
  85. package/library/terminal/messages.ts +6 -6
  86. package/library/validation/README.md +1 -1
  87. package/library/validation/SPEC.md +1 -1
  88. package/library/workflow/index.ts +1 -0
  89. package/library/workflow/nodeConfigs.ts +130 -0
  90. package/package.json +8 -2
package/README.md CHANGED
@@ -71,8 +71,11 @@ npm page shows the source commit and the workflow that built it. The release
71
71
  tarball is also attached to each
72
72
  [GitHub Release](https://github.com/asus97178/bazis/releases).
73
73
 
74
- `bazis new` creates a project with `src/index.ts`, a root `AppModule`,
75
- `bazis.config.json`, scripts, a `/health` test and an `.env.example`.
74
+ `bazis new` creates a project with `src/app/App.ts` (the application declared by
75
+ `@App`), `src/index.ts` (`runApp(Application)`, no options), a root `AppModule`,
76
+ `bazis.config.json`, scripts, a `/health` test that starts the declared application
77
+ in the test process and an `.env.example` with `BAZIS_HTTP__HOST` and
78
+ `BAZIS_HTTP__PORT`.
76
79
 
77
80
  ## CLI
78
81
 
@@ -141,19 +144,25 @@ import { dbConfig } from "../config/db.config"; // defineConfig("db", { default:
141
144
  @Infra({ db: ormBazisConnect(dbConfig) })
142
145
  export class AppInfra {}
143
146
 
147
+ // src/app/App.ts
148
+ @App({ modules: [AppModule], infra: AppInfra, http: { health: true } })
149
+ export class Application {}
150
+
144
151
  // src/index.ts
145
- await runApp(AppModule, { infra: AppInfra, http: { port: 3000, health: true } });
152
+ await registerBazisGeneratedRuntime();
153
+ await runApp(Application);
146
154
  ```
147
155
 
148
156
  A feature module attaches its own `DbContext` and entities with
149
- `ormBazis: { context, entities }`; its services take that context in their
157
+ `ormBazis: { context }` (the entities of its sets are mapped by themselves); its services take that context in their
150
158
  constructors. `bunx bazis g module Task --minimal` generates such a module.
151
159
  Configuration values can be overridden from the environment
152
160
  (`BAZIS_DB__HOST`, `BAZIS_DB__PASSWORD`, ...). Invalid configuration or a
153
161
  missing production secret stops startup with an error that names the key.
154
162
 
155
163
  A unique index violation in `saveChanges()` rejects with
156
- `UniqueViolationError` (with `constraint` and `table`); catch it to answer 409.
164
+ `UniqueViolationError` (with `constraint`, `table` and `properties`); uncaught, it
165
+ answers 409 by itself, as other database errors answer 404, 422 or 503.
157
166
  Request-body fields declared as `string`, `number` or `boolean` are checked
158
167
  against the JSON type automatically (400 on mismatch); other rules come from
159
168
  `@Validator`.
@@ -180,11 +189,13 @@ import { CodexError, RU_CODEX_MESSAGES } from "bazis/core/infra";
180
189
  import { RU_UI_LABELS } from "bazis/core/app";
181
190
 
182
191
  CodexError.useMessages(RU_CODEX_MESSAGES); // Codex connector errors
183
- await runApp(AppModule, {
192
+ @App({
193
+ modules: [AppModule],
184
194
  language: "ru", // response texts: validation, 404/405, 401/403, binding errors
185
195
  messages: { notFound: "Нет такой страницы" }, // optional: own texts for single keys
186
- ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts
187
- });
196
+ ui: { labels: RU_UI_LABELS, surfaces: [/* ... */] }, // generated UI texts,
197
+ })
198
+ export class Application {}
188
199
  ```
189
200
 
190
201
  `language` is per application: it is applied when the response is written, so
@@ -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\nHOST=127.0.0.1\nPORT=3000\n"],
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/index.ts", 'import { runApp } from "bazis/core/app";\nimport { AppModule } from "./app/modules/App.module";\nimport { registerBazisGeneratedRuntime } from "./generated/bazis/runtime";\n\nawait registerBazisGeneratedRuntime();\nawait runApp(AppModule, { http: { hostname: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT ?? 3000), health: true } });\n'],
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: 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
+ : "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 app from source exactly as \`bazis dev\` does and checks /health.
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
- // A free port from the operating system, not a random guess that may be taken.
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
- let status = 0;
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.kill();
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 (runApp({ cache: memory() }))
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 (runApp cache: memory()).
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
- for (const [key, spec] of Object.entries(fields.permissions)) context.addMetadata(PERMISSION_CHANNEL, { key, spec });
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: add it to the module's providers.`);
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 nothing. */
11
- readonly grants: Readonly<Record<string, Readonly<Record<string, 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
- return sub === undefined || sub === "" ? undefined : { sub };
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
  }
@@ -2,6 +2,8 @@ import type { AgentDefinition } from "./AgentRegistry";
2
2
  import { AgentRuntimeError } from "./errors";
3
3
  import {
4
4
  agentMessage,
5
+ agentToolResult,
6
+ agentToolResultPart,
5
7
  type AgentContentPart,
6
8
  type AgentMessage,
7
9
  type AgentMessageRole,
@@ -9,10 +11,20 @@ import {
9
11
  type JsonValue,
10
12
  } from "./semantic";
11
13
 
14
+ /** A Tool result longer than this reaches the model cut, unless `maxToolResultChars` says otherwise. */
15
+ export const DEFAULT_MAX_TOOL_RESULT_CHARS = 100_000;
16
+
12
17
  export interface AgentContextLimits {
13
18
  readonly maxMessages?: number;
14
19
  readonly maxChars?: number;
15
20
  readonly maxTokens?: number;
21
+ /**
22
+ * The longest Tool output, as JSON text, the model gets in full. A longer
23
+ * one is sent as `{ truncated: true, totalChars, text }` with its beginning,
24
+ * so one large result does not fill the model's window; `result.toolResults`
25
+ * and `result.messages` keep the whole output. 100 000 by default.
26
+ */
27
+ readonly maxToolResultChars?: number;
16
28
  }
17
29
 
18
30
  export interface AgentContextBuildInput {
@@ -21,6 +33,12 @@ export interface AgentContextBuildInput {
21
33
  readonly messages: readonly AgentMessage[];
22
34
  readonly metadata: JsonObject;
23
35
  readonly limits?: AgentContextLimits;
36
+ /**
37
+ * How many real tokens one estimated token turned out to be for this model:
38
+ * the runtime measures it from the usage the provider reports. Estimates are
39
+ * multiplied by it before `maxTokens` is applied. 1 when nothing is known yet.
40
+ */
41
+ readonly tokenScale?: number;
24
42
  }
25
43
 
26
44
  export interface AgentContextTraceEntry {
@@ -55,6 +73,8 @@ interface ContextCandidate {
55
73
  readonly message: AgentMessage;
56
74
  readonly chars: number;
57
75
  readonly tokens: number;
76
+ /** The Tool result was too long and is sent cut. */
77
+ readonly cut?: true;
58
78
  }
59
79
 
60
80
  function errorMessageOf(error: unknown): string {
@@ -83,7 +103,11 @@ export function normalizeAgentContextLimits(
83
103
  return undefined;
84
104
  }
85
105
 
86
- const normalized: { maxMessages?: number; maxChars?: number; maxTokens?: number } = {};
106
+ const normalized: { maxMessages?: number; maxChars?: number; maxTokens?: number; maxToolResultChars?: number } = {};
107
+ const maxToolResultChars = optionalPositiveInteger(limits.maxToolResultChars, `${field}.maxToolResultChars`);
108
+ if (maxToolResultChars !== undefined) {
109
+ normalized.maxToolResultChars = maxToolResultChars;
110
+ }
87
111
  const maxMessages = optionalPositiveInteger(limits.maxMessages, `${field}.maxMessages`);
88
112
  const maxChars = optionalPositiveInteger(limits.maxChars, `${field}.maxChars`);
89
113
  const maxTokens = optionalPositiveInteger(limits.maxTokens, `${field}.maxTokens`);
@@ -129,10 +153,48 @@ function cloneMessage(message: AgentMessage, field: string): AgentMessage {
129
153
  }
130
154
  }
131
155
 
156
+ /** The message the model gets instead of a Tool result that is too long; `undefined` when it fits. */
157
+ function cutToolResult(message: AgentMessage, maxChars: number): AgentMessage | undefined {
158
+ const part = message.content[0];
159
+ if (message.role !== "tool" || part?.kind !== "tool-result" || part.result.status !== "success") return undefined;
160
+ const json = JSON.stringify(part.result.output) ?? "";
161
+ if (json.length <= maxChars) return undefined;
162
+ const result = agentToolResult({
163
+ callId: part.result.callId,
164
+ name: part.result.name,
165
+ status: "success",
166
+ output: { truncated: true, totalChars: json.length, text: json.slice(0, maxChars) },
167
+ durationMs: part.result.durationMs,
168
+ metadata: part.result.metadata,
169
+ });
170
+ return agentMessage("tool", agentToolResultPart(result), {
171
+ id: message.id, name: message.name, toolCallId: message.toolCallId, createdAtUnixMs: message.createdAtUnixMs, metadata: message.metadata,
172
+ });
173
+ }
174
+
175
+ function withoutReasoning(message: AgentMessage): AgentMessage {
176
+ if (!message.content.some((part) => part.kind === "reasoning")) return message;
177
+ const content = message.content.filter((part) => part.kind !== "reasoning");
178
+ if (content.length === 0) return message;
179
+ return agentMessage(message.role, content, { id: message.id, name: message.name, toolCallId: message.toolCallId, createdAtUnixMs: message.createdAtUnixMs, metadata: message.metadata });
180
+ }
181
+
132
182
  function jsonLength(value: unknown): number {
133
183
  return JSON.stringify(value)?.length ?? 0;
134
184
  }
135
185
 
186
+ /** Tokens of an image whatever its size: providers scale images down to about this much. */
187
+ const IMAGE_TOKENS = 1_200;
188
+
189
+ function isDataUri(uri: string): boolean {
190
+ return uri.startsWith("data:");
191
+ }
192
+
193
+ /** A file in the request costs what its content does; an address alone says nothing about it. */
194
+ function fileTokens(uri: string): number {
195
+ return isDataUri(uri) ? Math.min(200_000, Math.max(500, Math.ceil(uri.length / 12))) : 2_000;
196
+ }
197
+
136
198
  function contentPartChars(part: AgentContentPart): number {
137
199
  switch (part.kind) {
138
200
  case "text":
@@ -140,9 +202,12 @@ function contentPartChars(part: AgentContentPart): number {
140
202
  case "data":
141
203
  return jsonLength(part.value) + (part.name?.length ?? 0);
142
204
  case "image":
143
- return part.uri.length + (part.mediaType?.length ?? 0) + part.detail.length;
205
+ // Bytes written into the dialog are not characters of a prompt.
206
+ return (isDataUri(part.uri) ? IMAGE_TOKENS * 4 : part.uri.length) + (part.mediaType?.length ?? 0) + part.detail.length;
144
207
  case "file":
145
- return part.uri.length + (part.mediaType?.length ?? 0) + (part.name?.length ?? 0);
208
+ return (isDataUri(part.uri) ? fileTokens(part.uri) * 4 : part.uri.length) + (part.mediaType?.length ?? 0) + (part.name?.length ?? 0);
209
+ case "reasoning":
210
+ return part.text.length + (part.data === undefined ? 0 : jsonLength(part.data));
146
211
  case "tool-result":
147
212
  return jsonLength(part.result);
148
213
  case "tool-call":
@@ -150,9 +215,35 @@ function contentPartChars(part: AgentContentPart): number {
150
215
  }
151
216
  }
152
217
 
218
+ /**
219
+ * An estimate close to what tokenizers produce, without a tokenizer: Latin
220
+ * letters and digits are about four characters a token, letters of other
221
+ * alphabets about two, a CJK character is a token, punctuation is about two
222
+ * marks a token. The runtime corrects the rest by the usage a provider reports.
223
+ */
153
224
  function textTokenCount(text: string): number {
154
- const matches = text.match(/[\p{L}\p{N}_]+|[^\s]/gu);
155
- return matches?.length ?? 0;
225
+ let latin = 0;
226
+ let letters = 0;
227
+ let marks = 0;
228
+ let whole = 0;
229
+ for (let index = 0; index < text.length; index += 1) {
230
+ const code = text.charCodeAt(index);
231
+ if (code <= 0x20) continue;
232
+ if (code < 0x80) {
233
+ if ((code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || code === 0x5f) latin += 1;
234
+ else marks += 1;
235
+ } else if (code === 0xa0 || (code >= 0x2000 && code <= 0x200b) || code === 0x3000) {
236
+ continue;
237
+ } else if ((code >= 0x2e80 && code <= 0x9fff) || (code >= 0xac00 && code <= 0xd7af) || (code >= 0xf900 && code <= 0xfaff)) {
238
+ whole += 1;
239
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
240
+ // The second half of a surrogate pair: its first half was counted.
241
+ continue;
242
+ } else {
243
+ letters += 1;
244
+ }
245
+ }
246
+ return Math.ceil(latin / 4 + letters / 2 + marks / 2 + whole);
156
247
  }
157
248
 
158
249
  function contentPartTokens(part: AgentContentPart): number {
@@ -162,9 +253,12 @@ function contentPartTokens(part: AgentContentPart): number {
162
253
  case "data":
163
254
  return textTokenCount(JSON.stringify(part.value) ?? "") + (part.name === undefined ? 0 : textTokenCount(part.name));
164
255
  case "image":
165
- return textTokenCount(JSON.stringify({ uri: part.uri, mediaType: part.mediaType ?? null, detail: part.detail }));
256
+ return IMAGE_TOKENS;
166
257
  case "file":
167
- return textTokenCount(JSON.stringify({ uri: part.uri, mediaType: part.mediaType ?? null, name: part.name ?? null }));
258
+ return fileTokens(part.uri);
259
+ case "reasoning":
260
+ // The provider's record of a block is about as long as the reasoning it holds.
261
+ return Math.max(textTokenCount(part.text), part.data === undefined ? 0 : Math.ceil(jsonLength(part.data) / 4));
168
262
  case "tool-result":
169
263
  return textTokenCount(JSON.stringify(part.result) ?? "");
170
264
  case "tool-call":
@@ -387,13 +481,15 @@ export class DefaultAgentContextBuilder implements AgentContextBuilder {
387
481
 
388
482
  build(input: AgentContextBuildInput): AgentContext {
389
483
  const limits = normalizeAgentContextLimits(input.limits);
390
- const candidates = this.candidates(input.agent, input.messages);
484
+ const tokenScale = input.tokenScale !== undefined && Number.isFinite(input.tokenScale) && input.tokenScale > 0 ? input.tokenScale : 1;
485
+ const candidates = this.candidates(input.agent, input.messages, limits?.maxToolResultChars ?? DEFAULT_MAX_TOOL_RESULT_CHARS, tokenScale);
391
486
  if (candidates.length === 0) {
392
487
  throw new AgentRuntimeError("agent context requires at least one message.");
393
488
  }
394
489
 
395
490
  const included = candidates.map(() => true);
396
- const reasons = candidates.map(() => undefined as string | undefined);
491
+ // A result sent cut stays in the context; the trace says why it is shorter.
492
+ const reasons = candidates.map((candidate) => candidate.cut ? "maxToolResultChars" : undefined as string | undefined);
397
493
  const protectedItems = protectedIndexes(candidates);
398
494
  const selection = new ContextSelection(candidates, included, reasons, protectedItems);
399
495
  selection.apply("maxMessages", limits?.maxMessages);
@@ -402,12 +498,15 @@ export class DefaultAgentContextBuilder implements AgentContextBuilder {
402
498
 
403
499
  const messages: AgentMessage[] = [];
404
500
  const trace: AgentContextTraceEntry[] = [];
501
+ // A provider ties reasoning to the dialog it was written in: once older
502
+ // messages are dropped, the reasoning in the rest no longer matches it.
503
+ const trimmed = included.includes(false);
405
504
  for (let index = 0; index < candidates.length; index += 1) {
406
505
  const candidate = candidates[index] as ContextCandidate;
407
506
  const isIncluded = included[index] === true;
408
507
  trace.push(traceEntry(candidate, isIncluded, reasons[index]));
409
508
  if (isIncluded) {
410
- messages.push(candidate.message);
509
+ messages.push(trimmed ? withoutReasoning(candidate.message) : candidate.message);
411
510
  }
412
511
  }
413
512
 
@@ -421,16 +520,17 @@ export class DefaultAgentContextBuilder implements AgentContextBuilder {
421
520
  messageCount: messages.length,
422
521
  charCount,
423
522
  tokenCount,
523
+ ...(tokenScale === 1 ? {} : { tokenScale }),
424
524
  trace: traceToJson(trace),
425
525
  }),
426
526
  });
427
527
  }
428
528
 
429
- private candidates(agent: AgentDefinition, messages: readonly AgentMessage[]): readonly ContextCandidate[] {
529
+ private candidates(agent: AgentDefinition, messages: readonly AgentMessage[], maxToolResultChars: number, tokenScale: number): readonly ContextCandidate[] {
430
530
  const candidates: ContextCandidate[] = [];
431
531
  const prompt = this.promptMessage(agent);
432
532
  if (prompt !== undefined) {
433
- const tokens = this.estimatedTokens(prompt, "prompt");
533
+ const tokens = this.estimatedTokens(prompt, "prompt", tokenScale);
434
534
  candidates.push(Object.freeze({
435
535
  section: "prompt",
436
536
  message: prompt,
@@ -440,19 +540,22 @@ export class DefaultAgentContextBuilder implements AgentContextBuilder {
440
540
  }
441
541
 
442
542
  for (let index = 0; index < messages.length; index += 1) {
443
- const message = cloneMessage(messages[index] as AgentMessage, `agentContext.messages[${index}]`);
444
- const tokens = this.estimatedTokens(message, `conversation[${index}]`);
543
+ const whole = cloneMessage(messages[index] as AgentMessage, `agentContext.messages[${index}]`);
544
+ const cut = cutToolResult(whole, maxToolResultChars);
545
+ const message = cut ?? whole;
546
+ const tokens = this.estimatedTokens(message, `conversation[${index}]`, tokenScale);
445
547
  candidates.push(Object.freeze({
446
548
  section: `conversation[${index}]`,
447
549
  message,
448
550
  chars: messageChars(message),
449
551
  tokens,
552
+ ...(cut === undefined ? {} : { cut: true as const }),
450
553
  }));
451
554
  }
452
555
  return Object.freeze(candidates);
453
556
  }
454
557
 
455
- private estimatedTokens(message: AgentMessage, field: string): number {
558
+ private estimatedTokens(message: AgentMessage, field: string, tokenScale: number): number {
456
559
  let estimate: unknown;
457
560
  try { estimate = this.tokenEstimator.estimateMessage(message); } catch (error) {
458
561
  throw new AgentRuntimeError(`agentContext token estimator failed for ${field}: ${errorMessageOf(error)}`);
@@ -460,7 +563,7 @@ export class DefaultAgentContextBuilder implements AgentContextBuilder {
460
563
  if (!Number.isFinite(estimate) || typeof estimate !== "number" || estimate < 0) {
461
564
  throw new AgentRuntimeError(`agentContext token estimator returned an invalid estimate for ${field}.`);
462
565
  }
463
- return estimate;
566
+ return tokenScale === 1 ? estimate : Math.ceil(estimate * tokenScale);
464
567
  }
465
568
 
466
569
  /** The definition's instructions are the system prompt, sent as written. */
@@ -14,6 +14,8 @@ import {
14
14
  import type { AgentModelProvider } from "./ModelProvider";
15
15
 
16
16
  export {
17
+ type AgentCompactionOptions,
18
+ type AgentModelRetryPolicy,
17
19
  type AgentRuntimeInvokeOptions,
18
20
  type AgentExecutionDriverOptions,
19
21
  type AgentRuntimeOptions,
@@ -58,7 +60,13 @@ export class AgentRuntime {
58
60
  this.options = {
59
61
  ...options,
60
62
  toolExecutorOptions: { schemaValidator: modelValidatorAdapter, ...toolExecutorOptions },
63
+ // What a model's tokens turned out to cost is learned once for every run.
64
+ tokenScales: new Map<string, number>(),
61
65
  };
66
+ const summaryProfile = options.compaction === undefined || options.compaction === false ? undefined : options.compaction.modelProfile;
67
+ if (summaryProfile !== undefined && typeof modelProvider.modelProfiles === "function" && !modelProvider.modelProfiles().includes(summaryProfile)) {
68
+ throw new AgentSetupError(`AgentRuntime.compaction: model profile "${summaryProfile}" is not configured (configured: ${[...modelProvider.modelProfiles()].sort().join(", ")}).`);
69
+ }
62
70
  // Invalid options and hook setup fail here, at the first resolve, not on the first run.
63
71
  new AgentExecutionDriver(services, this.catalog, modelProvider, this.options);
64
72
  }
@@ -903,6 +903,13 @@ export class AgentToolExecutor {
903
903
  return this.finish(call, result, startedAt, metadata, options, audit, tool, options.agentName, undefined, executionState);
904
904
  }
905
905
 
906
+ // The provider could not read the arguments the model wrote: the Tool is
907
+ // not run, and the model gets the reason so it can write the call again.
908
+ if (call.inputError !== undefined) {
909
+ const result = this.errorResult(call, "TOOL_ARGUMENTS_INVALID", `Tool "${tool.metadata.name}" arguments are not valid JSON: ${call.inputError}`, metadata, startedAt, { phase: "pre-execute" });
910
+ return this.finish(call, result, startedAt, metadata, options, audit, tool, visibility.agentName, undefined, executionState);
911
+ }
912
+
906
913
  const schema = this.schemaSettings(options);
907
914
  const schemaGate = this.ensureSchemaValidator(call, tool, schema, metadata, startedAt);
908
915
  if (schemaGate) {
@@ -22,6 +22,12 @@ export interface AgentModelProvider {
22
22
  * the model is called. Without it any profile is passed through.
23
23
  */
24
24
  modelProfiles?(): readonly string[];
25
+ /**
26
+ * The context window of the model behind a profile, in tokens, when infra
27
+ * knows it (`contextTokens` of the LLM connector). The runtime starts
28
+ * compacting a long dialog before it reaches the window.
29
+ */
30
+ contextTokens?(modelProfile?: string): number | undefined;
25
31
  }
26
32
 
27
33
  export const AGENT_MODEL_PROVIDER = createToken<AgentModelProvider>("AgentModelProvider");
@@ -86,11 +86,11 @@ are shared by agents and `ToolRunner`:
86
86
  ```ts
87
87
  @Module({
88
88
  providers: [
89
- DI.singleton(DI.valueProvider(AGENT_RUNTIME_OPTIONS, { maxSteps: 6 })),
90
- DI.singleton(DI.valueProvider(TOOL_EXECUTION_OPTIONS, {
89
+ singletonValue(AGENT_RUNTIME_OPTIONS, { maxSteps: 6 }),
90
+ singletonValue(TOOL_EXECUTION_OPTIONS, {
91
91
  approvalPolicy: (request) => request.tool.metadata.sideEffect === "read",
92
92
  auditSink: (entry) => auditStore.write(entry),
93
- })),
93
+ }),
94
94
  ],
95
95
  })
96
96
  export class AgentSettingsModule {}
@@ -144,6 +144,38 @@ await this.agents.run(support, {
144
144
  conversation message when trimming old history. Provider requests include
145
145
  `metadata.agentContext.trace` so context assembly is inspectable.
146
146
 
147
+ The runtime recovers before it fails a run. A model request the provider
148
+ cannot serve right now (`AgentModelUnavailableError`: HTTP 429, 5xx, a broken
149
+ connection) is repeated, three attempts by default (`modelRetry`). A text
150
+ answer cut by the output limit is continued (`maxOutputContinuations`, 3), a
151
+ structured answer that is not valid is sent back to the model with the issues
152
+ (`maxOutputRepairs`, 2); the finished dialog keeps one answer. Tool arguments
153
+ the provider could not read (`AgentToolCall.inputError`) give the model
154
+ `TOOL_ARGUMENTS_INVALID` instead of failing the run. Tools of one step that
155
+ change nothing (`none`, `read`) and stand next to each other run together
156
+ (`maxParallelToolCalls`, 8); `write` and `external` run one by one.
157
+ `runTimeoutMs` bounds a whole run (`AGENT_RUN_TIMEOUT`), `result.usage` sums
158
+ the tokens of every model call, and a Tool output longer than
159
+ `contextLimits.maxToolResultChars` (100 000) reaches the model cut.
160
+
161
+ A long dialog is compacted: its older part is replaced by a summary the
162
+ model writes (a user message with `metadata.runtime: "summary"`), the newest
163
+ messages stay as written and a Tool call is never separated from its results.
164
+ It starts at 80% of the model's window when the LLM connector states
165
+ `contextTokens`, at `compaction.maxTokens`, or after the provider refuses a
166
+ request as too long (`AgentModelContextOverflowError`); `result.compactions`
167
+ says it happened and `compaction: false` turns it off. Token estimates are
168
+ corrected by the usage a provider reports (`metadata.agentContext.tokenScale`).
169
+
170
+ A model's reasoning is a `reasoning` content part of its assistant message
171
+ (`agentReasoning(text, { data })`): `data` is the provider's own record of the
172
+ block and goes back to it unchanged with the dialog. The runtime drops
173
+ reasoning wherever it rewrites a dialog (compaction, trimming by
174
+ `contextLimits`, dropped drafts). `AgentUsage` also reports `cacheReadTokens`,
175
+ `cacheWriteTokens` and `reasoningTokens`; `inputTokens` is the whole request,
176
+ cached tokens included. Claude is served by `anthropicAdapter` from
177
+ `bazis/core/infra/anthropic`, on the official SDK.
178
+
147
179
  With `llmRouter`, `check` and `run` reject a `modelProfile` the router does
148
180
  not serve; a fallback to another profile is logged as `LLM model profile
149
181
  failed, using fallback`. A model provider that gives up after its own deadline
@@ -1,4 +1,5 @@
1
- import { DI, DiError, Global, HOSTED_SERVICE, Module, registerModuleMetadataExpander, SERVICE_PROVIDER, type BazisModuleMetadata, type ServiceProvider } from "../di";
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";