bazis 0.120.0 → 0.123.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.
@@ -49,7 +49,7 @@ export function buildModuleTemplates(
49
49
  return [
50
50
  ...base,
51
51
  { relativePath: `background/${naming.module}.reporter.ts`, content: statsReporterFile(naming) },
52
- { relativePath: `ai/tools/${naming.module}Summary.tool.ts`, content: aiToolFile(naming) },
52
+ { relativePath: `tools/${naming.module}Summary.tool.ts`, content: toolFile(naming) },
53
53
  ];
54
54
  }
55
55
 
@@ -57,7 +57,7 @@ function moduleFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
57
57
  const moduleImport = "import { Module, scoped } from \"bazis/core/di\";";
58
58
  const backgroundImport = profile === "full" ? `import { ${n.module}Reporter } from "./background/${n.module}.reporter";\n` : "";
59
59
  const aiImports = profile === "full"
60
- ? `import { ${n.module}SummaryTool } from "./ai/tools/${n.module}Summary.tool";
60
+ ? `import { ${n.module}SummaryTool } from "./tools/${n.module}Summary.tool";
61
61
  `
62
62
  : "";
63
63
  const backgroundLine = profile === "full" ? ` background: [${n.module}Reporter],\n` : "";
@@ -397,10 +397,10 @@ export class ${n.module}Reporter extends PeriodicBackgroundService {
397
397
  }
398
398
 
399
399
 
400
- function aiToolFile(n: ModuleNaming): string {
400
+ function toolFile(n: ModuleNaming): string {
401
401
  return `import { Tool, type AgentToolExecutionContext } from "bazis/core/agent";
402
402
  import { Validator } from "bazis/library/validation";
403
- import type { I${n.module}Service } from "../../services/I${n.module}.service";
403
+ import type { I${n.module}Service } from "../services/I${n.module}.service";
404
404
 
405
405
  export class ${n.module}SummaryToolInput {
406
406
  @Validator({ required: true, minLength: 3 })
@@ -420,6 +420,7 @@ export class ${n.module}SummaryToolOutput {
420
420
  input: ${n.module}SummaryToolInput,
421
421
  output: ${n.module}SummaryToolOutput,
422
422
  sideEffect: "read",
423
+ callers: ["agent"],
423
424
  })
424
425
  export class ${n.module}SummaryTool {
425
426
  constructor(private readonly ${n.collection}: I${n.module}Service) {}
@@ -66,7 +66,7 @@ adapters that access it.
66
66
  | ${e}Response / ${m}Summary / to${e}Response | [responses](http/contracts/${m}.responses.ts) | ORM entity | Public data projection |
67
67
  | ${m}ListQuery | [list](http/contracts/${m}List.query.ts) | Query string | ListQuery, filters/sorting/pages |
68
68
  ${profile === "full" ? `| ${m}Reporter | [background](background/${m}.reporter.ts) | ServiceProvider, Logger, AbortSignal | Runs count and logs it |
69
- | ${m}SummaryTool | [tool](ai/tools/${m}Summary.tool.ts) | I${m}Service, input, context | Summary for an agent |
69
+ | ${m}SummaryTool | [tool](tools/${m}Summary.tool.ts) | I${m}Service, input, context | Summary for an agent |
70
70
  ` : ""}
71
71
 
72
72
  ## Wiring, DI and data
@@ -1,11 +1,13 @@
1
1
  import { createToken, type ServiceProvider } from "../di";
2
2
  import { modelValidatorAdapter } from "../../library/validation";
3
3
  import { AgentSetupError } from "./errors";
4
+ import { TOOL_EXECUTION_OPTIONS } from "./toolExecutionOptions";
4
5
  import type { AgentDataDefinition } from "./AgentDataDefinition";
5
6
  import { AgentRegistry, type ToolDefinition } from "./AgentRegistry";
6
7
  import {
7
8
  AgentExecutionDriver,
8
9
  type AgentRuntimeInvokeOptions,
10
+ type AgentExecutionDriverOptions,
9
11
  type AgentRuntimeOptions,
10
12
  type AgentRuntimeResult,
11
13
  } from "./internal/AgentExecutionDriver";
@@ -13,6 +15,7 @@ import type { AgentModelProvider } from "./ModelProvider";
13
15
 
14
16
  export {
15
17
  type AgentRuntimeInvokeOptions,
18
+ type AgentExecutionDriverOptions,
16
19
  type AgentRuntimeOptions,
17
20
  type AgentRuntimeResult,
18
21
  type AgentRuntimeStatus,
@@ -42,7 +45,7 @@ export const AGENT_RUNTIME_OPTIONS = createToken<AgentRuntimeOptions>("AgentRunt
42
45
  */
43
46
  export class AgentRuntime {
44
47
  private readonly catalog: AgentRegistry;
45
- private readonly options: AgentRuntimeOptions;
48
+ private readonly options: AgentExecutionDriverOptions;
46
49
 
47
50
  constructor(
48
51
  private readonly services: ServiceProvider,
@@ -50,9 +53,11 @@ export class AgentRuntime {
50
53
  options: AgentRuntimeOptions = {},
51
54
  ) {
52
55
  this.catalog = AgentRegistry.fromContainer(services);
56
+ // One Tool execution setup for agents and ToolRunner (TOOLS-001 §6).
57
+ const { logger: _toolLogger, ...toolExecutorOptions } = services.has(TOOL_EXECUTION_OPTIONS) ? services.resolve(TOOL_EXECUTION_OPTIONS) : {};
53
58
  this.options = {
54
59
  ...options,
55
- toolExecutorOptions: { schemaValidator: modelValidatorAdapter, ...options.toolExecutorOptions },
60
+ toolExecutorOptions: { schemaValidator: modelValidatorAdapter, ...toolExecutorOptions },
56
61
  };
57
62
  // Invalid options and hook setup fail here, at the first resolve, not on the first run.
58
63
  new AgentExecutionDriver(services, this.catalog, modelProvider, this.options);
@@ -1,4 +1,5 @@
1
1
  import { ToolContractValidator, toolContractName, toolErrorMessage as errorMessageOf, type AgentToolSchemaValidator } from "./internal/ToolContract.validator";
2
+ import type { ToolActor } from "./metadata";
2
3
  import { ToolAuditProjector, type AgentToolAuditEntry, type AgentToolAuditFailureMode } from "./internal/ToolAudit.projector";
3
4
  export type { AgentToolValidationIssue, AgentToolValidationResult, AgentToolSchemaValidator } from "./internal/ToolContract.validator";
4
5
  export type { AgentToolAuditPhase, AgentToolAuditFailureMode, AgentToolAuditToolInfo, AgentToolAuditEntry } from "./internal/ToolAudit.projector";
@@ -69,6 +70,9 @@ export interface AgentToolApprovalRequest {
69
70
  readonly call: AgentToolCall;
70
71
  readonly tool: ToolDefinition;
71
72
  readonly reason: AgentToolApprovalReason;
73
+ /** `"agent"` for an agent call, otherwise the runner's caller id. */
74
+ readonly caller: string;
75
+ readonly actor?: ToolActor;
72
76
  readonly agentName?: string;
73
77
  readonly invocationId?: string;
74
78
  readonly idempotencyKey?: string;
@@ -111,7 +115,6 @@ export interface AgentToolExecutorOptions {
111
115
  readonly defaultTimeoutMs?: number;
112
116
  /** Maximum time to wait for a tool DI scope disposer after execution settles. */
113
117
  readonly scopeDisposeTimeoutMs?: number;
114
- readonly allowUnboundTools?: boolean;
115
118
  readonly allowUnvalidatedSchemas?: boolean;
116
119
  /** Side-effecting tools require an audit sink unless this explicit escape hatch is set. */
117
120
  readonly allowUnauditedSideEffects?: boolean;
@@ -123,6 +126,13 @@ export interface AgentToolExecutorOptions {
123
126
 
124
127
  export interface AgentToolExecutionOptions {
125
128
  readonly agentName?: string;
129
+ /**
130
+ * Who calls the Tool when there is no agent (`ToolRunner.as("workflow")`).
131
+ * An agent call is the `"agent"` caller. The Tool's `callers` must list it.
132
+ */
133
+ readonly caller?: string;
134
+ /** On whose behalf the call runs; reaches the approval policy and the audit. */
135
+ readonly actor?: ToolActor;
126
136
  readonly invocationId?: string;
127
137
  readonly metadata?: unknown;
128
138
  readonly approvalPolicy?: AgentToolApprovalPolicy;
@@ -135,7 +145,6 @@ export interface AgentToolExecutionOptions {
135
145
  readonly timeoutMs?: number;
136
146
  /** Per-call override for the bounded DI scope disposal wait. */
137
147
  readonly scopeDisposeTimeoutMs?: number;
138
- readonly allowUnboundTools?: boolean;
139
148
  readonly allowUnvalidatedSchemas?: boolean;
140
149
  readonly allowUnauditedSideEffects?: boolean;
141
150
  readonly signal?: AbortSignal;
@@ -418,7 +427,6 @@ export class AgentToolExecutor {
418
427
  private readonly retryPolicy: RetrySettings;
419
428
  private readonly defaultTimeoutMs?: number;
420
429
  private readonly scopeDisposeTimeoutMs: number;
421
- private readonly allowUnboundTools: boolean;
422
430
  private readonly allowUnvalidatedSchemas: boolean;
423
431
  private readonly allowUnauditedSideEffects: boolean;
424
432
  private readonly auditProjector: ToolAuditProjector;
@@ -445,7 +453,6 @@ export class AgentToolExecutor {
445
453
  ? undefined
446
454
  : options.defaultTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
447
455
  this.scopeDisposeTimeoutMs = options.scopeDisposeTimeoutMs ?? DEFAULT_SCOPE_DISPOSE_TIMEOUT_MS;
448
- this.allowUnboundTools = options.allowUnboundTools === true;
449
456
  this.allowUnvalidatedSchemas = options.allowUnvalidatedSchemas === true;
450
457
  this.allowUnauditedSideEffects = options.allowUnauditedSideEffects === true;
451
458
  this.auditProjector = new ToolAuditProjector(options.auditRedaction ?? {});
@@ -1240,18 +1247,27 @@ export class AgentToolExecutor {
1240
1247
  tool: ToolDefinition,
1241
1248
  options: AgentToolExecutionOptions,
1242
1249
  ): AgentVisibilityDecision {
1243
- const allowUnboundTools = options.allowUnboundTools ?? this.allowUnboundTools;
1244
- if (!options.agentName) {
1245
- if (allowUnboundTools) {
1246
- return { allowed: true };
1247
- }
1250
+ // Who may call is declared on the Tool (TOOLS-001); tags never decide it.
1251
+ const caller = callerOf(options);
1252
+ if (caller === undefined) {
1248
1253
  return {
1249
1254
  allowed: false,
1250
- code: "TOOL_AGENT_REQUIRED",
1251
- message: `Tool "${tool.metadata.name}" execution requires agentName.`,
1255
+ code: "TOOL_CALLER_REQUIRED",
1256
+ message: `Tool "${tool.metadata.name}" execution requires an agent or a caller.`,
1252
1257
  status: "error",
1253
1258
  };
1254
1259
  }
1260
+ if (!tool.metadata.callers.includes(caller)) {
1261
+ return {
1262
+ allowed: false,
1263
+ code: "TOOL_CALLER_NOT_ALLOWED",
1264
+ message: `Tool "${tool.metadata.name}" may not be called by "${caller}" (callers: ${tool.metadata.callers.join(", ")}).`,
1265
+ status: "denied",
1266
+ };
1267
+ }
1268
+ if (!options.agentName) {
1269
+ return { allowed: true };
1270
+ }
1255
1271
 
1256
1272
  const agent = this.registry.getAgent(options.agentName);
1257
1273
  if (!agent) {
@@ -1314,6 +1330,8 @@ export class AgentToolExecutor {
1314
1330
  call,
1315
1331
  tool,
1316
1332
  reason,
1333
+ caller: callerOf(options) ?? "",
1334
+ ...(options.actor === undefined ? {} : { actor: options.actor }),
1317
1335
  agentName: options.agentName,
1318
1336
  invocationId: options.invocationId,
1319
1337
  idempotencyKey,
@@ -2206,3 +2224,8 @@ export class AgentToolExecutor {
2206
2224
  return Math.max(0, Date.now() - startedAt);
2207
2225
  }
2208
2226
  }
2227
+
2228
+ /** The caller of a call: an agent call is `"agent"`, otherwise the explicit caller. */
2229
+ function callerOf(options: AgentToolExecutionOptions): string | undefined {
2230
+ return options.agentName ? "agent" : options.caller;
2231
+ }
@@ -79,24 +79,25 @@ A class schema comes from codegen (the class must be reachable for OpenAPI or
79
79
  be a Tool contract); `agentJsonSchema(name, schema)` takes a JSON Schema
80
80
  directly.
81
81
 
82
- Runtime settings — limits, approval policy, audit sink, context builder —
83
- are registered once:
82
+ Runtime settings (limits, context builder) and Tool execution settings
83
+ (approval policy, audit sink, retries) are registered once; the Tool settings
84
+ are shared by agents and `ToolRunner`:
84
85
 
85
86
  ```ts
86
87
  @Module({
87
- providers: [DI.singleton(DI.valueProvider(AGENT_RUNTIME_OPTIONS, {
88
- maxSteps: 6,
89
- toolExecutorOptions: {
90
- approvalPolicy: (request) => request.tool.sideEffect === "read",
88
+ providers: [
89
+ DI.singleton(DI.valueProvider(AGENT_RUNTIME_OPTIONS, { maxSteps: 6 })),
90
+ DI.singleton(DI.valueProvider(TOOL_EXECUTION_OPTIONS, {
91
+ approvalPolicy: (request) => request.tool.metadata.sideEffect === "read",
91
92
  auditSink: (entry) => auditStore.write(entry),
92
- },
93
- }))],
93
+ })),
94
+ ],
94
95
  })
95
96
  export class AgentSettingsModule {}
96
97
  ```
97
98
 
98
99
  Tool input and output are validated by the model validator
99
- (`modelValidatorAdapter`) unless `toolExecutorOptions.schemaValidator` says
100
+ (`modelValidatorAdapter`) unless `TOOL_EXECUTION_OPTIONS.schemaValidator` says
100
101
  otherwise. Audit entries redact `input`, `result` and execution `metadata`
101
102
  by default using `bazis/library/redaction`.
102
103
 
@@ -0,0 +1,135 @@
1
+ import { DI, DiError, Global, HOSTED_SERVICE, Module, registerModuleMetadataExpander, SERVICE_PROVIDER, type BazisModuleMetadata, type ServiceProvider } from "../di";
2
+ import { LOGGER } from "../kernel/logging/Logger";
3
+ import { modelValidatorAdapter } from "../../library/validation";
4
+ import { AgentRegistry, type ToolDefinition } from "./AgentRegistry";
5
+ import { AgentToolExecutor, type AgentToolExecutionOptions } from "./AgentToolExecutor";
6
+ import { AgentSetupError } from "./errors";
7
+ import { callerId, type ToolActor } from "./metadata";
8
+ import { describeTool } from "./Tool.contract";
9
+ import { agentToolCall, agentToolResult, type AgentToolContract, type AgentToolResult } from "./semantic";
10
+ import { TOOL_EXECUTION_OPTIONS, type ToolExecutionOptions } from "./toolExecutionOptions";
11
+
12
+ /** One call of {@link BoundToolRunner.run}. */
13
+ export interface ToolRunOptions extends Omit<AgentToolExecutionOptions, "agentName" | "caller" | "actor"> {
14
+ /** Id of the call in audit and hooks; a random uuid by default. */
15
+ readonly callId?: string;
16
+ /** On whose behalf the Tool runs. Required for `write` and `external` Tools. */
17
+ readonly actor?: ToolActor;
18
+ }
19
+
20
+ /**
21
+ * Runs the application's Tools without a model (TOOLS-001 §4). The framework
22
+ * registers it whenever a module declares `tools`; code binds it to its caller
23
+ * id and sees only the Tools whose `callers` list that id:
24
+ *
25
+ * ```ts
26
+ * constructor(runner: ToolRunner) { this.tools = runner.as("workflow"); }
27
+ * const result = await this.tools.run(step.toolName, input, { actor, idempotencyKey: stepId });
28
+ * ```
29
+ *
30
+ * The call goes through the same executor as an agent's call: input and output
31
+ * validation, DI scope, approval, audit, hooks, retries and deadline, configured
32
+ * once with `TOOL_EXECUTION_OPTIONS`.
33
+ */
34
+ export class ToolRunner {
35
+ private readonly catalog: AgentRegistry;
36
+ private readonly executor: AgentToolExecutor;
37
+
38
+ constructor(services: ServiceProvider, options: ToolExecutionOptions = {}) {
39
+ const { logger, ...executorOptions } = options;
40
+ this.catalog = AgentRegistry.fromContainer(services);
41
+ // Invalid options and hook setup fail here, when the runner is built at start.
42
+ this.executor = new AgentToolExecutor(services, this.catalog, { schemaValidator: modelValidatorAdapter, ...executorOptions }, logger);
43
+ }
44
+
45
+ /** A view of the Tools this caller may run. `"agent"` is refused: agents run through `AgentRuntime`. */
46
+ as(caller: string): BoundToolRunner {
47
+ let id: string;
48
+ try {
49
+ id = callerId(caller);
50
+ } catch {
51
+ throw new AgentSetupError(`ToolRunner.as(${JSON.stringify(caller)}): a caller id is lower-case, such as "workflow".`);
52
+ }
53
+ if (id === "agent") throw new AgentSetupError('ToolRunner.as("agent"): agents run Tools through AgentRuntime.');
54
+ return new BoundToolRunner(id, this.catalog, this.executor);
55
+ }
56
+ }
57
+
58
+ /** {@link ToolRunner} bound to one caller id: it sees and runs only that caller's Tools. */
59
+ export class BoundToolRunner {
60
+ constructor(
61
+ readonly caller: string,
62
+ private readonly catalog: AgentRegistry,
63
+ private readonly executor: AgentToolExecutor,
64
+ ) {}
65
+
66
+ /** The Tools this caller may run, optionally only those that carry every one of `tags`. */
67
+ listTools(tags: readonly string[] = []): readonly ToolDefinition[] {
68
+ return this.catalog.listTools().filter((tool) => tool.metadata.callers.includes(this.caller) && tags.every((tag) => tool.metadata.tags.includes(tag)));
69
+ }
70
+
71
+ /** The Tool registered under `name` if this caller may run it, otherwise `undefined`. */
72
+ getTool(name: string): ToolDefinition | undefined {
73
+ const tool = this.catalog.getTool(name);
74
+ return tool?.metadata.callers.includes(this.caller) ? tool : undefined;
75
+ }
76
+
77
+ /** The contract with the generated input and output JSON schemas, or `undefined`. */
78
+ describe(name: string): AgentToolContract | undefined {
79
+ const tool = this.getTool(name);
80
+ return tool === undefined ? undefined : describeTool(tool);
81
+ }
82
+
83
+ /**
84
+ * Runs the Tool `name` with `input`. Never throws for a Tool failure: an
85
+ * unknown name, a Tool this caller may not run (TOOL_CALLER_NOT_ALLOWED),
86
+ * invalid input, denial, timeout or an exception comes back as the result.
87
+ */
88
+ run(name: string, input: unknown, options: ToolRunOptions = {}): Promise<AgentToolResult> {
89
+ const { callId, actor, ...execution } = options;
90
+ const call = agentToolCall({ id: callId ?? crypto.randomUUID(), name, input: input as never });
91
+ const tool = this.catalog.getTool(name);
92
+ if (tool !== undefined && actor === undefined && (tool.metadata.sideEffect === "write" || tool.metadata.sideEffect === "external") && tool.metadata.callers.includes(this.caller)) {
93
+ return Promise.resolve(agentToolResult({
94
+ callId: call.id,
95
+ name,
96
+ status: "error",
97
+ error: { code: "TOOL_ACTOR_REQUIRED", message: `Tool "${name}" changes data: pass actor, the user or system the call runs for.` },
98
+ durationMs: 0,
99
+ metadata: {},
100
+ }));
101
+ }
102
+ return this.executor.execute(call, { ...execution, caller: this.caller, ...(actor === undefined ? {} : { actor }) });
103
+ }
104
+ }
105
+
106
+ /** Provides ToolRunner once per application and builds it at start, so wrong options stop the start. */
107
+ @Global()
108
+ @Module({
109
+ providers: [
110
+ DI.singleton(DI.factoryProviderWithResolver(ToolRunner, [], (resolver) => {
111
+ const services = resolver.resolve(SERVICE_PROVIDER);
112
+ const options = services.has(TOOL_EXECUTION_OPTIONS) ? services.resolve(TOOL_EXECUTION_OPTIONS) : {};
113
+ return new ToolRunner(services, { ...options, logger: options.logger ?? services.tryResolve(LOGGER) });
114
+ })),
115
+ DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => ({
116
+ start() {
117
+ try {
118
+ resolver.resolve(ToolRunner);
119
+ } catch (error) {
120
+ if (error instanceof DiError) throw error;
121
+ throw new DiError(`ToolRunner: ${error instanceof Error ? error.message : String(error)}`);
122
+ }
123
+ },
124
+ stop() {},
125
+ }))),
126
+ ],
127
+ exports: [ToolRunner],
128
+ })
129
+ class ToolRunnerModule {}
130
+
131
+ // A module that declares Tools brings the runner; importing it twice is one module.
132
+ registerModuleMetadataExpander((metadata: BazisModuleMetadata) => {
133
+ const tools = (metadata as { readonly tools?: readonly unknown[] }).tools;
134
+ return Array.isArray(tools) && tools.length > 0 ? [ToolRunnerModule] : [];
135
+ });
@@ -36,6 +36,8 @@ export {
36
36
  type AgentRuntimeToolCallContext,
37
37
  type AgentRuntimeToolExecutionOptions,
38
38
  } from "./AgentRuntime";
39
+ export { BoundToolRunner, ToolRunner, type ToolRunOptions } from "./ToolRunner";
40
+ export { TOOL_EXECUTION_OPTIONS, type ToolExecutionOptions } from "./toolExecutionOptions";
39
41
  export {
40
42
  ApproximateAgentContextTokenEstimator,
41
43
  DefaultAgentContextBuilder,
@@ -80,7 +80,6 @@ export interface AgentRuntimeOptions {
80
80
  readonly timeoutMs?: number;
81
81
  readonly contextBuilder?: AgentContextBuilder;
82
82
  readonly contextLimits?: AgentContextLimits;
83
- readonly toolExecutorOptions?: AgentToolExecutorOptions;
84
83
  /**
85
84
  * Where failures of the model and unexpected tool failures go, with their
86
85
  * stack. The LLM connectors pass the application logger.
@@ -88,6 +87,14 @@ export interface AgentRuntimeOptions {
88
87
  readonly logger?: Logger;
89
88
  }
90
89
 
90
+ /**
91
+ * @internal The driver also takes the Tool execution settings; `AgentRuntime`
92
+ * reads them from `TOOL_EXECUTION_OPTIONS`, shared with `ToolRunner`.
93
+ */
94
+ export interface AgentExecutionDriverOptions extends AgentRuntimeOptions {
95
+ readonly toolExecutorOptions?: AgentToolExecutorOptions;
96
+ }
97
+
91
98
  export interface AgentRuntimeInvokeOptions {
92
99
  readonly id?: string;
93
100
  readonly input?: unknown;
@@ -515,7 +522,7 @@ export class AgentExecutionDriver {
515
522
  services: ServiceProvider,
516
523
  private readonly registry: AgentRegistry,
517
524
  private readonly modelProvider: AgentModelProvider,
518
- options: AgentRuntimeOptions = {},
525
+ options: AgentExecutionDriverOptions = {},
519
526
  ) {
520
527
  assertPositiveInteger(options.maxSteps, "AgentRuntime.maxSteps");
521
528
  assertPositiveInteger(options.maxToolCallsPerStep, "AgentRuntime.maxToolCallsPerStep");
@@ -1,4 +1,5 @@
1
1
  import type { SensitiveRedactionOptions } from "../../../library/redaction";
2
+ import type { ToolActor } from "../metadata";
2
3
  import { redactSensitive } from "../../../library/redaction";
3
4
  import type { ToolDefinition } from "../AgentRegistry";
4
5
  import type { AgentToolAuditHookProjectionV1 } from "../AgentToolHooks";
@@ -23,6 +24,10 @@ export interface AgentToolAuditEntry {
23
24
  readonly phase: AgentToolAuditPhase;
24
25
  readonly callId: string;
25
26
  readonly toolName: string;
27
+ /** `"agent"` for an agent call, otherwise the runner's caller id. */
28
+ readonly caller?: string;
29
+ /** On whose behalf the call ran. */
30
+ readonly actor?: ToolActor;
26
31
  readonly agentName?: string;
27
32
  readonly invocationId?: string;
28
33
  readonly attempt?: number;
@@ -39,6 +44,8 @@ export interface AgentToolAuditEntry {
39
44
  }
40
45
 
41
46
  interface ToolAuditOptions {
47
+ readonly caller?: string;
48
+ readonly actor?: ToolActor;
42
49
  readonly agentName?: string;
43
50
  readonly invocationId?: string;
44
51
  readonly auditRedaction?: SensitiveRedactionOptions | false;
@@ -70,6 +77,8 @@ export class ToolAuditProjector {
70
77
  phase: AgentToolAuditPhase;
71
78
  callId: string;
72
79
  toolName: string;
80
+ caller?: string;
81
+ actor?: ToolActor;
73
82
  agentName?: string;
74
83
  invocationId?: string;
75
84
  attempt?: number;
@@ -92,6 +101,13 @@ export class ToolAuditProjector {
92
101
  metadata: this.redactAuditValue(metadata, options) as JsonObject,
93
102
  };
94
103
 
104
+ const caller = options.agentName !== undefined || agentName !== undefined ? "agent" : options.caller;
105
+ if (caller !== undefined) {
106
+ entry.caller = caller;
107
+ }
108
+ if (options.actor !== undefined) {
109
+ entry.actor = options.actor;
110
+ }
95
111
  if (agentName !== undefined || options.agentName !== undefined) {
96
112
  entry.agentName = agentName ?? options.agentName;
97
113
  }
@@ -8,6 +8,12 @@ import { AgentSetupError } from "./errors";
8
8
  const TOOL_META = Symbol.for("bazis:agent:tool");
9
9
 
10
10
  export type ToolSideEffect = "none" | "read" | "write" | "external";
11
+
12
+ /** On whose behalf a Tool runs: a user, or the system (a process, a job). */
13
+ export interface ToolActor {
14
+ readonly kind: "user" | "system";
15
+ readonly id: string;
16
+ }
11
17
  export type ToolApproval = "never" | "policy" | "required";
12
18
 
13
19
  export interface ToolOptions {
@@ -18,6 +24,13 @@ export interface ToolOptions {
18
24
  readonly sideEffect?: ToolSideEffect;
19
25
  readonly approval?: ToolApproval;
20
26
  readonly timeoutMs?: number;
27
+ /**
28
+ * Who may call the Tool: `"agent"` for LLM agents, any other id for code that
29
+ * runs Tools itself (`ToolRunner.as("workflow")`). Required and non-empty;
30
+ * a caller not listed is refused with TOOL_CALLER_NOT_ALLOWED.
31
+ */
32
+ readonly callers: readonly string[];
33
+ /** Grouping and search only; tags never decide who may call a Tool. */
21
34
  readonly tags?: readonly string[];
22
35
  }
23
36
 
@@ -29,6 +42,7 @@ export interface ToolMetadata {
29
42
  readonly sideEffect: ToolSideEffect;
30
43
  readonly approval: ToolApproval;
31
44
  readonly timeoutMs?: number;
45
+ readonly callers: readonly string[];
32
46
  readonly tags: readonly string[];
33
47
  }
34
48
 
@@ -51,6 +65,14 @@ function readonlyTextList(values: readonly string[] | undefined, field: string):
51
65
  return Object.freeze(values.map((value) => text(value, field)));
52
66
  }
53
67
 
68
+ /** A caller id: `agent` or an application id such as `workflow`. */
69
+ export function callerId(value: unknown): string {
70
+ if (typeof value !== "string" || !/^[a-z][a-z0-9_.-]{0,63}$/u.test(value)) {
71
+ throw new AgentSetupError(`@Tool.callers: ${JSON.stringify(value)} is not a caller id such as "agent" or "workflow".`);
72
+ }
73
+ return value;
74
+ }
75
+
54
76
  function positiveInteger(value: number | undefined, field: string): number | undefined {
55
77
  if (value === undefined) {
56
78
  return undefined;
@@ -89,6 +111,9 @@ export function defineToolMetadata(_target: object, metadata: object, options: T
89
111
  sideEffect,
90
112
  approval: approvalFor(sideEffect, options.approval),
91
113
  timeoutMs: positiveInteger(options.timeoutMs, "@Tool.timeoutMs"),
114
+ // Checked when the module registers the Tool, so a missing list prints as
115
+ // one configuration-error line at start instead of failing the import.
116
+ callers: Array.isArray(options.callers) ? Object.freeze(options.callers.map((caller) => callerId(caller))) : Object.freeze([]),
92
117
  tags: readonlyTextList(options.tags, "@Tool.tags"),
93
118
  });
94
119
  (metadata as MetadataCarrier)[TOOL_META] = normalized;
@@ -60,7 +60,11 @@ registerModuleOwnedProviderContributor((metadata: BazisModuleMetadata, context)
60
60
  const owner = metadata as BazisModuleRef;
61
61
  const fields = metadata as BazisModuleMetadata & AgentModuleFields;
62
62
  for (const target of classValues(fields.tools, "tools")) {
63
- if (!toolMetadataOf(target)) throw new AgentSetupError(`${target.name || "<anonymous class>"} is listed as a tool but is not decorated with @Tool(...).`);
63
+ const tool = toolMetadataOf(target);
64
+ if (!tool) throw new AgentSetupError(`${target.name || "<anonymous class>"} is listed as a tool but is not decorated with @Tool(...).`);
65
+ if (tool.callers.length === 0) {
66
+ throw new AgentSetupError(`Tool "${tool.name}" (${target.name || "<anonymous class>"}) declares no callers: list who may call it, for example callers: ["agent"] for LLM agents or ["workflow"] for a process engine.`);
67
+ }
64
68
  try {
65
69
  context.registerScoped(TOOL_CHANNEL, scoped(target), Object.freeze({ target, owner }));
66
70
  } catch (error) {
@@ -0,0 +1,15 @@
1
+ import { createToken } from "../di";
2
+ import type { Logger } from "../kernel/logging/Logger";
3
+ import type { AgentToolExecutorOptions } from "./AgentToolExecutor";
4
+
5
+ /**
6
+ * How Tools run for every caller: approval policy, audit sink, retries,
7
+ * deadlines and hooks. Register one value in any module; `AgentRuntime` and
8
+ * `ToolRunner` read it from the root container (TOOLS-001 §6).
9
+ */
10
+ export interface ToolExecutionOptions extends AgentToolExecutorOptions {
11
+ /** Where unexpected Tool failures go with their stack. Defaults to the application logger. */
12
+ readonly logger?: Logger;
13
+ }
14
+
15
+ export const TOOL_EXECUTION_OPTIONS = createToken<ToolExecutionOptions>("ToolExecutionOptions");
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.120.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.123.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -42,11 +42,12 @@ More scenarios (complex conditions, async, nested objects) are in
42
42
 
43
43
  | File | Purpose |
44
44
  |---|---|
45
- | `Validator.ts` | public facade: the decorator + `validate`/`validateAsync`/`setDefaultMessages` |
45
+ | `Validator.ts` | public facade: the decorator + `validate`/`validateAsync`/`ruleSet`/`setDefaultMessages` |
46
46
  | `ValidationError.ts` | one error: `property`, `value`, `message`, `code` |
47
47
  | `ValidationResult.ts` | the outcome: `isValid`, `errors`, `getErrorsFor`, `hasErrorsFor` |
48
48
  | `RuleEngine.ts` | the engine: one pass over the plan, sync/async, nested, cycle protection |
49
49
  | `RuleCompiler.ts` | compiles class rules into a plan + WeakMap cache (RegExp, enum Set) |
50
+ | `RuleSet.ts` | rules as data without a class: `Validator.ruleSet({ field: options })` |
50
51
  | `MessageRegistry.ts` | message templates, placeholders, priority local > global > built-in; built-in texts are English, `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaults` and `runApp({ language: "ru" })` |
51
52
  | `metadata.ts` | stores rules through `context.metadata` / `Symbol.metadata` |
52
53
  | `types/ValidatorOptions.ts` | all decorator options |
@@ -1,4 +1,4 @@
1
- import { rulesOf } from "./metadata";
1
+ import { rulesOf, type FieldRule } from "./metadata";
2
2
  import type { ValidatorOptions } from "./types/ValidatorOptions";
3
3
 
4
4
  /**
@@ -69,9 +69,18 @@ export class RuleCompiler {
69
69
  if (!rules) {
70
70
  return undefined;
71
71
  }
72
+ const plan = RuleCompiler.compileRules(rules);
73
+ RuleCompiler.cache.set(ctor, plan);
74
+ return plan;
75
+ }
76
+
77
+ /**
78
+ * Compiles a rule list that is not tied to a class (see `RuleSet`). Not
79
+ * cached: the caller keeps the result for as long as the rules are valid.
80
+ */
81
+ static compileRules(rules: readonly FieldRule[]): CompiledRule[] {
72
82
  const plan = rules.map((rule) => RuleCompiler.compile(rule.property, rule.options));
73
83
  RuleCompiler.assignNestedCarriers(plan);
74
- RuleCompiler.cache.set(ctor, plan);
75
84
  return plan;
76
85
  }
77
86
 
@@ -49,6 +49,24 @@ export class RuleEngine {
49
49
  return new ValidationResult(errors);
50
50
  }
51
51
 
52
+ /** Synchronous validation of any object by a precompiled plan (see `RuleSet`). */
53
+ static validatePlan(plan: readonly CompiledRule[], value: object): ValidationResult {
54
+ const errors: ValidationError[] = [];
55
+ RuleEngine.run(plan, value, "", errors, undefined, undefined);
56
+ return new ValidationResult(errors);
57
+ }
58
+
59
+ /** Asynchronous validation of any object by a precompiled plan (see `RuleSet`). */
60
+ static async validatePlanAsync(plan: readonly CompiledRule[], value: object): Promise<ValidationResult> {
61
+ const errors: ValidationError[] = [];
62
+ const jobs: AsyncJob[] = [];
63
+ RuleEngine.run(plan, value, "", errors, jobs, undefined);
64
+ for (const job of jobs) {
65
+ await job();
66
+ }
67
+ return new ValidationResult(errors);
68
+ }
69
+
52
70
  /**
53
71
  * Asynchronous validation: all synchronous rules run immediately, async
54
72
  * `custom` functions run sequentially after them (deterministic error order).
@@ -75,6 +93,17 @@ export class RuleEngine {
75
93
  if (!plan) {
76
94
  return;
77
95
  }
96
+ RuleEngine.run(plan, instance, prefix, errors, jobs, seen);
97
+ }
98
+
99
+ private static run(
100
+ plan: readonly CompiledRule[],
101
+ instance: object,
102
+ prefix: string,
103
+ errors: ValidationError[],
104
+ jobs: AsyncJob[] | undefined,
105
+ seen: Set<object> | undefined,
106
+ ): void {
78
107
  for (const rule of plan) {
79
108
  RuleEngine.applyRule(rule, instance, prefix, errors, jobs, seen);
80
109
  }
@@ -0,0 +1,59 @@
1
+ import { RuleCompiler, type CompiledRule } from "./RuleCompiler";
2
+ import { RuleEngine } from "./RuleEngine";
3
+ import type { ValidationResult } from "./ValidationResult";
4
+ import type { FieldRule } from "./metadata";
5
+ import type { ValidatorOptions } from "./types/ValidatorOptions";
6
+
7
+ /** Field rules of a {@link RuleSet}: one options object or several, as several decorators on one field. */
8
+ export type FieldRules = Readonly<Record<string, ValidatorOptions | readonly ValidatorOptions[]>>;
9
+
10
+ /**
11
+ * Validation rules that are data, not a decorated class: for objects whose
12
+ * shape is known only at run time (rows of tables created by users, rules
13
+ * stored in a database). The same engine, options, codes and messages as
14
+ * `@Validator`; the rules are compiled once in the constructor.
15
+ *
16
+ * ```ts
17
+ * const rules = Validator.ruleSet({
18
+ * title: { required: true, maxLength: 200 },
19
+ * amount: [{ type: "number", min: 0 }, { integer: true }],
20
+ * });
21
+ * rules.validate({ title: "", amount: -1.5 }).errors;
22
+ * ```
23
+ *
24
+ * Only the named fields are read. A field name that exists on
25
+ * `Object.prototype` (`__proto__`, `constructor`, `toString`…) is rejected,
26
+ * so a plain object never yields an inherited value.
27
+ */
28
+ export class RuleSet {
29
+ private readonly plan: readonly CompiledRule[];
30
+
31
+ constructor(rules: FieldRules) {
32
+ const list: FieldRule[] = [];
33
+ for (const property of Object.keys(rules)) {
34
+ if (property in Object.prototype) {
35
+ throw new TypeError(`RuleSet: field "${property}" is not allowed: it is a property of Object.prototype.`);
36
+ }
37
+ const options = rules[property]!;
38
+ for (const item of Array.isArray(options) ? options as readonly ValidatorOptions[] : [options as ValidatorOptions]) {
39
+ list.push({ property, options: item });
40
+ }
41
+ }
42
+ this.plan = RuleCompiler.compileRules(list);
43
+ }
44
+
45
+ /** Field names that have rules. */
46
+ get fields(): readonly string[] {
47
+ return [...new Set(this.plan.map((rule) => rule.property))];
48
+ }
49
+
50
+ /** Synchronous validation; an async `custom` adds an `asyncCustomInSyncCall` error. */
51
+ validate(value: object): ValidationResult {
52
+ return RuleEngine.validatePlan(this.plan, value);
53
+ }
54
+
55
+ /** Asynchronous validation: awaits async `custom` functions. */
56
+ validateAsync(value: object): Promise<ValidationResult> {
57
+ return RuleEngine.validatePlanAsync(this.plan, value);
58
+ }
59
+ }
@@ -412,6 +412,34 @@ Validator.validate(new ChildDto()); // checks both id and extra
412
412
  Validator.validate(new BaseDto()); // only id
413
413
  ```
414
414
 
415
+ ### 4.13 Rules as data: `Validator.ruleSet`
416
+
417
+ When the fields are known only at run time (a table created by a user, rules
418
+ stored in a database), there is no class to decorate. `Validator.ruleSet`
419
+ takes the same options per field name and compiles them once:
420
+
421
+ ```ts
422
+ const rules = Validator.ruleSet({
423
+ title: { required: true, maxLength: 200 },
424
+ amount: [{ type: "number", min: 0 }, { integer: true }], // like two decorators
425
+ });
426
+
427
+ rules.validate({ title: "", amount: -1.5 }).errors;
428
+ // [amount: min, amount: integer] — "" is a value, so required passes
429
+ await rules.validateAsync(row); // when a rule has an async custom
430
+ rules.fields; // ["title", "amount"]
431
+ ```
432
+
433
+ - Codes, paths, `params` and messages are the same as with `@Validator`.
434
+ - The plan is compiled in the constructor (RegExp, enum Set) and not cached
435
+ globally: keep the `RuleSet` while the rules are valid and build a new one
436
+ when they change.
437
+ - Only the named fields are read. A field name that exists on
438
+ `Object.prototype` (`__proto__`, `constructor`, `toString`…) throws
439
+ `TypeError`, so a plain object never yields an inherited value.
440
+ - `nested: true` validates a nested value only if it is an instance of a
441
+ decorated class, as with decorators.
442
+
415
443
  ---
416
444
 
417
445
  ## 5. Result: `ValidationResult` and `ValidationError`
@@ -1,5 +1,6 @@
1
1
  import { MessageRegistry } from "./MessageRegistry";
2
2
  import { RuleEngine } from "./RuleEngine";
3
+ import { RuleSet, type FieldRules } from "./RuleSet";
3
4
  import type { ValidationResult } from "./ValidationResult";
4
5
  import { registerFieldRule } from "./metadata";
5
6
  import type { ValidatorOptions } from "./types/ValidatorOptions";
@@ -74,6 +75,18 @@ export const Validator = Object.assign(
74
75
  validateAsync(instance: object): Promise<ValidationResult> {
75
76
  return RuleEngine.validateAsync(instance);
76
77
  },
78
+ /**
79
+ * Rules as data, without a class: for objects whose fields are known only
80
+ * at run time. Compiled once; keep the result and reuse it.
81
+ *
82
+ * ```ts
83
+ * const rules = Validator.ruleSet({ title: { required: true, maxLength: 200 } });
84
+ * rules.validate(row).isValid;
85
+ * ```
86
+ */
87
+ ruleSet(rules: FieldRules): RuleSet {
88
+ return new RuleSet(rules);
89
+ },
77
90
 
78
91
  /**
79
92
  * Globally overrides the standard error messages.
@@ -8,6 +8,7 @@
8
8
  * methods `validate` / `validateAsync` / `setDefaultMessages`.
9
9
  */
10
10
  export { Validator } from "./Validator";
11
+ export { RuleSet, type FieldRules } from "./RuleSet";
11
12
  export { modelValidatorAdapter } from "./modelValidatorAdapter";
12
13
  export { ValidationError } from "./ValidationError";
13
14
  export { ValidationResult } from "./ValidationResult";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.120.0",
3
+ "version": "0.123.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",