@kindgi/handler-runtime 0.1.4-rc.5 → 0.1.5-rc.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 (44) hide show
  1. package/README.md +1 -0
  2. package/dist/handler-runner.d.ts +21 -3
  3. package/dist/handler-runner.d.ts.map +1 -1
  4. package/dist/handler-runner.js +40 -0
  5. package/dist/handler-runner.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/kindgi-index.d.ts +50 -10
  11. package/dist/kindgi-index.d.ts.map +1 -1
  12. package/dist/kindgi-index.js +154 -12
  13. package/dist/kindgi-index.js.map +1 -1
  14. package/dist/pack-env.d.ts +3 -3
  15. package/dist/pack-service/main.d.ts +8 -2
  16. package/dist/pack-service/main.d.ts.map +1 -1
  17. package/dist/pack-service/main.js +38 -19
  18. package/dist/pack-service/main.js.map +1 -1
  19. package/dist/pack-service/records.d.ts +45 -0
  20. package/dist/pack-service/records.d.ts.map +1 -0
  21. package/dist/pack-service/records.js +73 -0
  22. package/dist/pack-service/records.js.map +1 -0
  23. package/dist/pack-service/service.d.ts +7 -0
  24. package/dist/pack-service/service.d.ts.map +1 -1
  25. package/dist/pack-service/service.js +75 -13
  26. package/dist/pack-service/service.js.map +1 -1
  27. package/dist/pack-service/supervisor.d.ts +24 -1
  28. package/dist/pack-service/supervisor.d.ts.map +1 -1
  29. package/dist/pack-service/supervisor.js +53 -10
  30. package/dist/pack-service/supervisor.js.map +1 -1
  31. package/dist/protocol.d.ts +7 -2
  32. package/dist/protocol.d.ts.map +1 -1
  33. package/dist/protocol.js +2 -2
  34. package/dist/protocol.js.map +1 -1
  35. package/package.json +9 -7
  36. package/src/handler-runner.ts +62 -4
  37. package/src/index.ts +6 -0
  38. package/src/kindgi-index.ts +192 -17
  39. package/src/pack-env.ts +3 -3
  40. package/src/pack-service/main.ts +39 -20
  41. package/src/pack-service/records.ts +99 -0
  42. package/src/pack-service/service.ts +96 -16
  43. package/src/pack-service/supervisor.ts +69 -12
  44. package/src/protocol.ts +7 -2
@@ -16,7 +16,8 @@
16
16
  * 3. calls `handler(input, ctx)`;
17
17
  * 4. validates the output against the tool's output JSON Schema.
18
18
  *
19
- * A check imports the module, resolves its `evaluate`, and calls
19
+ * A check validates the config against the guardrail's `configSchema`
20
+ * (as sent), imports the module, resolves its `evaluate`, and calls
20
21
  * `evaluate(config, trace, bindings)`.
21
22
  *
22
23
  * Neither throws: every failure is a typed `HandlerError`. The runner
@@ -24,17 +25,19 @@
24
25
  * pack code is the team's own (trusted).
25
26
  */
26
27
 
27
- import type { ValidateFunction } from 'ajv';
28
+ import type { ErrorObject, ValidateFunction } from 'ajv';
28
29
  import * as addFormatsModule from 'ajv-formats';
29
30
  import { Ajv2020 } from 'ajv/dist/2020.js';
30
31
 
32
+ import type { Logger } from '@kindgi/log';
31
33
  import { type ZodLikeSchema, isZodSchema, parseWithSchema } from '@kindgi/schema';
32
34
  import type { Result } from '@kindgi/types';
33
35
 
34
36
  /**
35
37
  * What a handler gets beside its input: the tenant and run it serves,
36
- * and its resolved "typed needs" (env, secrets, config), composed by
37
- * the server and sent with the call.
38
+ * and the "typed needs" it declares (`needsSpec.env`, `needsSpec.secrets`),
39
+ * resolved by the runtime for this call and sent with it. `config` is
40
+ * reserved: no runtime sends it yet.
38
41
  */
39
42
  export interface HandlerContext {
40
43
  readonly tenantId: string;
@@ -44,6 +47,7 @@ export interface HandlerContext {
44
47
  /** The project's org, when it has one (2.3.0). */
45
48
  readonly orgId?: string;
46
49
  readonly requestId?: string;
50
+ /** The declared env values: project, else org, else tenant (protocol 2.5.0). */
47
51
  readonly env?: Readonly<Record<string, unknown>>;
48
52
  readonly secrets?: Readonly<Record<string, unknown>>;
49
53
  readonly config?: Readonly<Record<string, unknown>>;
@@ -59,6 +63,12 @@ export interface HandlerContext {
59
63
  * should pass it on so they stop promptly.
60
64
  */
61
65
  readonly abortSignal?: AbortSignal;
66
+ /**
67
+ * Never on the wire; the pack service adds it: a logger bound to the
68
+ * call (its tenant, run, request and trace). Not enumerable, like
69
+ * `secrets`.
70
+ */
71
+ readonly log?: Logger;
62
72
  }
63
73
 
64
74
  /**
@@ -87,6 +97,14 @@ export interface ToolInvocationSpec {
87
97
  export interface CheckInvocationSpec {
88
98
  readonly id: string;
89
99
  readonly modulePath: string;
100
+ /**
101
+ * The guardrail's `configSchema` from the index. A call's `config` that
102
+ * doesn't fit it is refused (`input-validation-failed`, with the
103
+ * issues) before the check runs. It is checked as sent, the schema's
104
+ * defaults not filled in, as the indexer checks a declared config; the
105
+ * check's own schema fills them in.
106
+ */
107
+ readonly configSchema?: Readonly<Record<string, unknown>>;
90
108
  }
91
109
 
92
110
  /** Every way a call can fail; the pack service answers with the same code. */
@@ -367,6 +385,35 @@ export async function runCheck(options: RunCheckOptions): Promise<Result<unknown
367
385
  const { check, config, trace } = options;
368
386
  const importCheck = options.importCheck ?? defaultImportCheck;
369
387
 
388
+ if (check.configSchema !== undefined) {
389
+ let configValidator: ValidateFunction;
390
+ try {
391
+ configValidator = compileSchema(check.configSchema, 'output');
392
+ } catch (cause) {
393
+ return {
394
+ kind: 'err',
395
+ error: {
396
+ code: 'input-validation-failed',
397
+ message: `Check "${check.id}" config schema failed to compile: ${stringifyError(cause)}`,
398
+ toolId: check.id,
399
+ cause: serializeCause(cause),
400
+ },
401
+ };
402
+ }
403
+ if (!configValidator(config)) {
404
+ const issues = configValidator.errors ?? [];
405
+ return {
406
+ kind: 'err',
407
+ error: {
408
+ code: 'input-validation-failed',
409
+ message: `Check "${check.id}" config failed validation${describeFirstIssue(issues)}`,
410
+ toolId: check.id,
411
+ issues,
412
+ },
413
+ };
414
+ }
415
+ }
416
+
370
417
  let module_: CheckModule;
371
418
  try {
372
419
  module_ = await importCheck(check.modulePath);
@@ -414,6 +461,17 @@ export async function runCheck(options: RunCheckOptions): Promise<Result<unknown
414
461
  return { kind: 'ok', value: result };
415
462
  }
416
463
 
464
+ /**
465
+ * Where the first issue is and what it says (` at /maxChars: must be >= 0`),
466
+ * for a message that is read without its issues: a runtime reports a
467
+ * check's error by its code and message alone.
468
+ */
469
+ function describeFirstIssue(issues: readonly ErrorObject[]): string {
470
+ const first = issues[0];
471
+ if (first === undefined) return '';
472
+ return `${first.instancePath === '' ? '' : ` at ${first.instancePath}`}: ${first.message ?? 'invalid'}`;
473
+ }
474
+
417
475
  function resolveCheckEvaluate(
418
476
  module_: CheckModule,
419
477
  ):
package/src/index.ts CHANGED
@@ -32,17 +32,23 @@ export type {
32
32
  KindgiProviderDeclaration,
33
33
  KindgiConfigFile,
34
34
  LoadKindgiConfigOptions,
35
+ JvmLanguage,
35
36
  PackLanguage,
36
37
  } from './kindgi-index.js';
37
38
  export {
38
39
  DEFAULT_DISCOVERY,
40
+ DEFAULT_JAVA_DISCOVERY,
41
+ DEFAULT_SCALA_DISCOVERY,
39
42
  DEFAULT_PYTHON_DISCOVERY,
40
43
  HELP_TEXT as KINDGI_INDEX_HELP_TEXT,
41
44
  INDEX_ENVELOPE_VERSION,
42
45
  KERNEL_PAYLOAD_VERSION,
43
46
  KINDGI_CONFIG_FILENAMES,
47
+ KINDGI_JSON_CONFIG_FILENAME,
44
48
  PYPROJECT_FILENAME,
49
+ RESERVED_CHECK_IDS,
45
50
  findKindgiConfig,
51
+ isJvmLanguage,
46
52
  loadKindgiConfig,
47
53
  packLanguage,
48
54
  resolveDiscovery,
@@ -89,9 +89,17 @@ export interface DiscoveryConfig {
89
89
  /**
90
90
  * The language a pack's code (tool handlers, guardrail checks) is written
91
91
  * in — which indexer reads it and which pack service runs it. Agents and
92
- * flows are data in either.
92
+ * flows are data in any of them.
93
93
  */
94
- export type PackLanguage = 'node' | 'python';
94
+ export type PackLanguage = 'node' | 'python' | 'java' | 'scala';
95
+
96
+ /** The languages that run on the JVM: built by Maven (Java) or sbt (Scala), indexed and served by kindgi-pack. */
97
+ export type JvmLanguage = Extract<PackLanguage, 'java' | 'scala'>;
98
+
99
+ /** Whether a pack's code runs on the JVM (kindgi-pack's indexer and pack service). */
100
+ export function isJvmLanguage(language: PackLanguage): language is JvmLanguage {
101
+ return language === 'java' || language === 'scala';
102
+ }
95
103
 
96
104
  /**
97
105
  * The subset of `kindgi.config.ts` this indexer consumes. Other
@@ -108,7 +116,7 @@ export interface KindgiConfig {
108
116
  /**
109
117
  * The pack's code language. Absent in `kindgi.config.*`: `node`. A
110
118
  * `[tool.kindgi]` table in `pyproject.toml` is a Python pack unless it
111
- * says otherwise.
119
+ * says otherwise. A `kindgi.config.json` says `"java"` or `"scala"`.
112
120
  */
113
121
  readonly language?: PackLanguage;
114
122
  /**
@@ -185,15 +193,53 @@ export const DEFAULT_PYTHON_DISCOVERY: Required<DiscoveryConfig> = {
185
193
  flows: 'flows/**/*.py',
186
194
  };
187
195
 
196
+ /**
197
+ * Default discovery patterns of a Java pack (`com.kindgi.pack.Main index`
198
+ * reads the same keys from `kindgi.config.json`): the source files under
199
+ * any `tools`, `guardrails`, `agents` or `flows` package.
200
+ */
201
+ export const DEFAULT_JAVA_DISCOVERY: Required<DiscoveryConfig> = {
202
+ tools: 'src/main/java/**/tools/**/*.java',
203
+ guardrails: 'src/main/java/**/guardrails/**/*.java',
204
+ agents: 'src/main/java/**/agents/**/*.java',
205
+ flows: 'src/main/java/**/flows/**/*.java',
206
+ };
207
+
208
+ /**
209
+ * Default discovery patterns of a Scala pack (kindgi-pack's indexer reads
210
+ * the same keys): the source files under any `tools`, `guardrails`, `agents`
211
+ * or `flows` package.
212
+ */
213
+ export const DEFAULT_SCALA_DISCOVERY: Required<DiscoveryConfig> = {
214
+ tools: 'src/main/scala/**/tools/**/*.scala',
215
+ guardrails: 'src/main/scala/**/guardrails/**/*.scala',
216
+ agents: 'src/main/scala/**/agents/**/*.scala',
217
+ flows: 'src/main/scala/**/flows/**/*.scala',
218
+ };
219
+
188
220
  /** A config's discovery patterns with the language's defaults filled in. */
189
221
  export function resolveDiscovery(
190
222
  discovery: DiscoveryConfig | undefined,
191
223
  language: PackLanguage = 'node',
192
224
  ): Required<DiscoveryConfig> {
193
- const defaults = language === 'python' ? DEFAULT_PYTHON_DISCOVERY : DEFAULT_DISCOVERY;
225
+ const defaults =
226
+ language === 'python'
227
+ ? DEFAULT_PYTHON_DISCOVERY
228
+ : language === 'java'
229
+ ? DEFAULT_JAVA_DISCOVERY
230
+ : language === 'scala'
231
+ ? DEFAULT_SCALA_DISCOVERY
232
+ : DEFAULT_DISCOVERY;
194
233
  return { ...defaults, ...(discovery ?? {}) };
195
234
  }
196
235
 
236
+ /** The command that indexes a pack of each language other than Node. */
237
+ const OWN_INDEXER: Readonly<Record<Exclude<PackLanguage, 'node'>, string>> = {
238
+ python: 'python -m kindgi.pack index',
239
+ java: 'java -cp <classpath> com.kindgi.pack.Main index',
240
+ scala: 'java -cp <classpath> com.kindgi.pack.Main index',
241
+ };
242
+
197
243
  // -----------------------------------------------------------------------
198
244
  // Manifest entry shapes emitted in index.json
199
245
  // -----------------------------------------------------------------------
@@ -272,6 +318,8 @@ export interface IndexedAgent {
272
318
  readonly output?: Readonly<Record<string, unknown>>;
273
319
  /** How the agent's turns retry failed tool calls (`{ maxRetries?, retryOn? }`). */
274
320
  readonly toolErrors?: Readonly<Record<string, unknown>>;
321
+ /** How the agent uses what it retrieves (`{ instructionTypes? }`). */
322
+ readonly memory?: Readonly<Record<string, unknown>>;
275
323
  readonly modulePath: string;
276
324
  }
277
325
 
@@ -329,7 +377,8 @@ export type IndexerErrorCode =
329
377
  | 'zod-conversion-failed'
330
378
  | 'manifest-validation-failed'
331
379
  | 'output-write-failed'
332
- | 'language-mismatch';
380
+ | 'language-mismatch'
381
+ | 'reserved-check-id';
333
382
 
334
383
  export interface IndexerError {
335
384
  readonly code: IndexerErrorCode;
@@ -434,7 +483,7 @@ export async function runIndexer(
434
483
  kind: 'err',
435
484
  error: {
436
485
  code: 'language-mismatch',
437
- message: `${packDir} is a ${packLanguage(config)} pack; index it with its own indexer (python -m kindgi.pack index)`,
486
+ message: `${packDir} is a ${packLanguage(config)} pack; index it with its own indexer (${OWN_INDEXER[packLanguage(config) as Exclude<PackLanguage, 'node'>]})`,
438
487
  },
439
488
  };
440
489
  }
@@ -590,6 +639,11 @@ export async function runIndexer(
590
639
  fileErrors.push(built.error);
591
640
  continue;
592
641
  }
642
+ const reserved = reservedCheckIn(unwrapped, module_, relPath);
643
+ if (reserved !== undefined) {
644
+ fileErrors.push(reserved);
645
+ continue;
646
+ }
593
647
  const duplicate = duplicateOf('guardrail', built.value.id, undefined, relPath);
594
648
  if (duplicate !== undefined) {
595
649
  fileErrors.push(duplicate);
@@ -937,19 +991,49 @@ export async function loadKindgiConfig(
937
991
  /** `pyproject.toml` — a Python pack keeps its config in the `[tool.kindgi]` table. */
938
992
  export const PYPROJECT_FILENAME = 'pyproject.toml';
939
993
 
994
+ /**
995
+ * `kindgi.config.json` — a JVM pack's config (`"language": "java"`): the
996
+ * same keys as `kindgi.config.ts`, as JSON, so Maven, Gradle and sbt builds
997
+ * share one file.
998
+ */
999
+ export const KINDGI_JSON_CONFIG_FILENAME = 'kindgi.config.json';
1000
+
940
1001
  export interface KindgiConfigFile {
941
1002
  readonly path: string;
942
- /** `module`: a `kindgi.config.*`; `pyproject`: the `[tool.kindgi]` table of a `pyproject.toml`. */
943
- readonly format: 'module' | 'pyproject';
1003
+ /**
1004
+ * `module`: a `kindgi.config.*` module; `pyproject`: the `[tool.kindgi]`
1005
+ * table of a `pyproject.toml`; `json`: a `kindgi.config.json`.
1006
+ */
1007
+ readonly format: 'module' | 'pyproject' | 'json';
1008
+ /**
1009
+ * Another config file at the same root, next to a `kindgi.config.json`:
1010
+ * loading the config refuses the pack, naming both.
1011
+ */
1012
+ readonly conflictsWith?: string;
944
1013
  }
945
1014
 
946
1015
  /**
947
- * Where a pack's config lives: the first `kindgi.config.*` at `packDir`
948
- * (`KINDGI_CONFIG_FILENAMES` order), else a `pyproject.toml` with a
949
- * `[tool.kindgi]` table. `undefined` when neither — a `pyproject.toml`
950
- * without the table is a Python project, not a pack.
1016
+ * Where a pack's config lives: the first `kindgi.config.*` module at
1017
+ * `packDir` (`KINDGI_CONFIG_FILENAMES` order), else a `pyproject.toml`
1018
+ * with a `[tool.kindgi]` table; a `kindgi.config.json` when it is the only
1019
+ * one. `undefined` when none — a `pyproject.toml` without the table is a
1020
+ * Python project, not a pack. A `kindgi.config.json` next to another
1021
+ * config file is returned with `conflictsWith`, and the loader refuses it.
951
1022
  */
952
1023
  export async function findKindgiConfig(packDir: string): Promise<KindgiConfigFile | undefined> {
1024
+ const other = await findModuleOrPyproject(packDir);
1025
+ const json = path.join(packDir, KINDGI_JSON_CONFIG_FILENAME);
1026
+ if (await exists(json)) {
1027
+ return {
1028
+ path: json,
1029
+ format: 'json',
1030
+ ...(other !== undefined && { conflictsWith: other.path }),
1031
+ };
1032
+ }
1033
+ return other;
1034
+ }
1035
+
1036
+ async function findModuleOrPyproject(packDir: string): Promise<KindgiConfigFile | undefined> {
953
1037
  for (const name of KINDGI_CONFIG_FILENAMES) {
954
1038
  const candidate = path.join(packDir, name);
955
1039
  if (await exists(candidate)) return { path: candidate, format: 'module' };
@@ -992,7 +1076,9 @@ async function loadConfig(
992
1076
  if (explicitPath !== undefined) {
993
1077
  const resolved = path.resolve(explicitPath);
994
1078
  if (await exists(resolved)) {
995
- const format = path.basename(resolved) === PYPROJECT_FILENAME ? 'pyproject' : 'module';
1079
+ const base = path.basename(resolved);
1080
+ const format =
1081
+ base === PYPROJECT_FILENAME ? 'pyproject' : base.endsWith('.json') ? 'json' : 'module';
996
1082
  file = { path: resolved, format };
997
1083
  }
998
1084
  } else {
@@ -1003,16 +1089,32 @@ async function loadConfig(
1003
1089
  kind: 'err',
1004
1090
  error: {
1005
1091
  code: 'config-not-found',
1006
- message: `No kindgi.config.{ts,mts,mjs,js,cjs}, or pyproject.toml with a [tool.kindgi] table, found at pack root ${packDir}`,
1092
+ message: `No kindgi.config.{ts,mts,mjs,js,cjs}, pyproject.toml with a [tool.kindgi] table, or kindgi.config.json, found at pack root ${packDir}`,
1093
+ },
1094
+ };
1095
+ }
1096
+ if (file.conflictsWith !== undefined) {
1097
+ const other = path.basename(file.conflictsWith);
1098
+ return {
1099
+ kind: 'err',
1100
+ error: {
1101
+ code: 'config-invalid',
1102
+ message:
1103
+ `${packDir} has two pack configs, ${KINDGI_JSON_CONFIG_FILENAME} and ${other}; a pack has one. ` +
1104
+ `Keep ${KINDGI_JSON_CONFIG_FILENAME} for a Java or Scala pack, or ${other} for a ` +
1105
+ `${file.conflictsWith.endsWith(PYPROJECT_FILENAME) ? 'Python' : 'TypeScript'} one, and remove the other.`,
1106
+ filePath: file.path,
1007
1107
  },
1008
1108
  };
1009
1109
  }
1010
1110
  const read =
1011
1111
  file.format === 'pyproject'
1012
1112
  ? await readPyprojectTable(file.path)
1013
- : await importConfigModule(file.path, importModule);
1113
+ : file.format === 'json'
1114
+ ? await readJsonConfig(file.path)
1115
+ : await importConfigModule(file.path, importModule);
1014
1116
  if (read.kind === 'err') return read;
1015
- const checked = checkConfig(read.value, file.path);
1117
+ const checked = checkConfig(read.value, file.path, file.format);
1016
1118
  if (checked.kind === 'err') return checked;
1017
1119
  const record = checked.value;
1018
1120
  return {
@@ -1084,10 +1186,26 @@ async function readPyprojectTable(
1084
1186
  return { kind: 'ok', value: table as Record<string, unknown> };
1085
1187
  }
1086
1188
 
1189
+ async function readJsonConfig(
1190
+ filePath: string,
1191
+ ): Promise<Result<Record<string, unknown>, IndexerError>> {
1192
+ let document: unknown;
1193
+ try {
1194
+ document = JSON.parse(await fs.readFile(filePath, 'utf8'));
1195
+ } catch (cause) {
1196
+ return parseFailed(filePath, `Failed to read ${filePath}: ${stringifyError(cause)}`, cause);
1197
+ }
1198
+ if (!isObject(document)) {
1199
+ return parseFailed(filePath, `Config file ${filePath}: the file must hold a JSON object`);
1200
+ }
1201
+ return { kind: 'ok', value: document as Record<string, unknown> };
1202
+ }
1203
+
1087
1204
  /** The checks every config passes, whatever file it came from. */
1088
1205
  function checkConfig(
1089
1206
  record: Record<string, unknown>,
1090
1207
  filePath: string,
1208
+ format: KindgiConfigFile['format'],
1091
1209
  ): Result<KindgiConfig, IndexerError> {
1092
1210
  const pack = record.pack;
1093
1211
  if (typeof pack !== 'object' || pack === null) {
@@ -1109,7 +1227,19 @@ function checkConfig(
1109
1227
  `Config file ${filePath}: 'pack.version' is missing or not a non-empty string`,
1110
1228
  );
1111
1229
  }
1112
- if (record.language !== undefined && record.language !== 'node' && record.language !== 'python') {
1230
+ if (format === 'json') {
1231
+ // kindgi.config.json is the JVM's config: it names its language.
1232
+ if (record.language !== 'java' && record.language !== 'scala') {
1233
+ return parseFailed(
1234
+ filePath,
1235
+ `Config file ${filePath}: ${KINDGI_JSON_CONFIG_FILENAME} is a JVM pack's config; it says "language": "java" or "scala"`,
1236
+ );
1237
+ }
1238
+ } else if (
1239
+ record.language !== undefined &&
1240
+ record.language !== 'node' &&
1241
+ record.language !== 'python'
1242
+ ) {
1113
1243
  return parseFailed(filePath, `Config file ${filePath}: 'language' must be "node" or "python"`);
1114
1244
  }
1115
1245
  return { kind: 'ok', value: record as KindgiConfig };
@@ -1368,6 +1498,50 @@ function buildTool(
1368
1498
  return { kind: 'ok', value: tool };
1369
1499
  }
1370
1500
 
1501
+ /**
1502
+ * The built-in checks' ids (`BUILT_IN_CHECK_IDS` in `@kindgi/guardrails`; a test holds the two
1503
+ * equal). A guardrail may name one (`check: 'must-cite'`) and the runtime runs the built-in; a
1504
+ * pack may never ship its own check under one, which the runtime would silently replace.
1505
+ */
1506
+ export const RESERVED_CHECK_IDS: readonly string[] = [
1507
+ 'must-cite',
1508
+ 'never-call-tool',
1509
+ 'max-tool-calls',
1510
+ 'output-matches',
1511
+ 'tool-order',
1512
+ 'required-substring',
1513
+ 'forbidden-substring',
1514
+ ];
1515
+
1516
+ /**
1517
+ * A check implementation (an object with an `id` and an `evaluate` function) under a built-in
1518
+ * id: the guardrail's own `check`, or any check the module exports. Naming a built-in by its id
1519
+ * (a string) is the way to use it, and is fine.
1520
+ */
1521
+ function reservedCheckIn(
1522
+ guardrail: unknown,
1523
+ module_: unknown,
1524
+ relPath: string,
1525
+ ): IndexerError | undefined {
1526
+ const candidates: unknown[] = [
1527
+ isObject(guardrail) ? (guardrail as Record<string, unknown>).check : undefined,
1528
+ ...(isObject(module_) ? Object.values(module_ as Record<string, unknown>) : []),
1529
+ ];
1530
+ for (const c of candidates) {
1531
+ if (!isObject(c)) continue;
1532
+ const rec = c as Record<string, unknown>;
1533
+ if (typeof rec.id !== 'string' || typeof rec.evaluate !== 'function') continue;
1534
+ if (!RESERVED_CHECK_IDS.includes(rec.id)) continue;
1535
+ return {
1536
+ code: 'reserved-check-id',
1537
+ message: `${relPath} ships its own check under "${rec.id}", a built-in check's id: a pack can't replace a built-in. Rename your check (for example "<pack>.checks.${rec.id}"), or, to use the built-in, name it (check: '${rec.id}') and drop your implementation.`,
1538
+ filePath: relPath,
1539
+ field: 'check',
1540
+ };
1541
+ }
1542
+ return undefined;
1543
+ }
1544
+
1371
1545
  function buildGuardrail(
1372
1546
  raw: unknown,
1373
1547
  relPath: string,
@@ -1510,6 +1684,7 @@ function optionalAgentFields(rec: Record<string, unknown>): Partial<IndexedAgent
1510
1684
  'output',
1511
1685
  'toolErrors',
1512
1686
  'modelSettings',
1687
+ 'memory',
1513
1688
  ] as const) {
1514
1689
  if (isObject(rec[key])) out[key] = rec[key];
1515
1690
  }
package/src/pack-env.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * libraries they import take from `process.env` (a database URL a client
7
7
  * reads at import, a bucket name). Declared once per pack, in
8
8
  * `kindgi.config` (`env: { required, optional }`; Python:
9
- * `[tool.kindgi.env]`), and carried in `index.json`, so the pack service
9
+ * `[tool.kindgi.env]`; Java and Scala: `env` in `kindgi.config.json`), and carried in `index.json`, so the pack service
10
10
  * in an image knows what it needs.
11
11
  *
12
12
  * A deployment injects exactly these names: `required` must be there for
@@ -14,8 +14,8 @@
14
14
  * has a value. Nothing else from an env file reaches the process.
15
15
  *
16
16
  * Distinct from `needsSpec.env` / `ctx.env`, the per-call values the
17
- * runtime resolves for a call's tenant, and from `needsSpec.secrets` /
18
- * `ctx.secrets`.
17
+ * runtime resolves for a call (its project's, else its org's, else its
18
+ * tenant's, from `/v1/env`), and from `needsSpec.secrets` / `ctx.secrets`.
19
19
  */
20
20
 
21
21
  /** A pack's declared process environment, as `index.json` carries it. */
@@ -30,8 +30,12 @@
30
30
  * broken build never becomes ready. SIGTERM drains in-flight calls
31
31
  * (readyz answers 503 meanwhile) and exits 0.
32
32
  *
33
- * Logs are JSON lines on stderr. The `listening` line carries the bound
34
- * port, for callers that start it with `PORT=0`.
33
+ * Logs are `@kindgi/log` records on stderr (subsystem `pack`), at
34
+ * `KINDGI_LOG_LEVEL` / `KINDGI_LOG_LEVELS`, in `KINDGI_LOG_FORMAT` (`auto`:
35
+ * JSON unless stderr is a terminal): a record per call, and the
36
+ * lifecycle (`listening`, `boot-failed`, `draining`, …) whatever the
37
+ * levels (`./records.ts`). The `listening` record carries the bound port,
38
+ * for callers that start it with `PORT=0`.
35
39
  */
36
40
 
37
41
  import { access, readFile } from 'node:fs/promises';
@@ -44,6 +48,7 @@ import { isProcessEntrypoint } from '../entrypoint.js';
44
48
  import type { Index } from '../kindgi-index.js';
45
49
  import { INDEX_ENVELOPE_VERSION, readBundleMap } from '../kindgi-index.js';
46
50
  import { PACK_ENV_CHECK_VAR, type PackEnvCheck, parsePackEnvCheck } from '../pack-env.js';
51
+ import { type PackServiceLogs, defaultPackServiceLogs, packServiceLogs } from './records.js';
47
52
  import { type PackService, type PackServiceLogEvent, createPackService } from './service.js';
48
53
 
49
54
  export interface PackServiceConfig {
@@ -132,11 +137,20 @@ type StartOutcome =
132
137
  | { readonly kind: 'ok'; readonly value: RunningPackService }
133
138
  | { readonly kind: 'err'; readonly problems: readonly string[] };
134
139
 
135
- /** Load the index, check and prewarm every module, then listen. */
140
+ /**
141
+ * Load the index, check and prewarm every module, then listen. `logs`:
142
+ * where its records go (default: stderr, at the default levels). A
143
+ * function instead gets the service's events as before records, and no
144
+ * records are written.
145
+ */
136
146
  export async function startPackService(
137
147
  config: PackServiceConfig,
138
- logger: (event: PackServiceLogEvent | Record<string, unknown>) => void = logJson,
148
+ logs:
149
+ | PackServiceLogs
150
+ | ((event: PackServiceLogEvent | Record<string, unknown>) => void) = defaultPackServiceLogs(),
139
151
  ): Promise<StartOutcome> {
152
+ const legacy = typeof logs === 'function' ? logs : undefined;
153
+ const records = typeof logs === 'function' ? undefined : logs;
140
154
  let index: Index;
141
155
  try {
142
156
  index = JSON.parse(await readFile(config.indexPath, 'utf8')) as Index;
@@ -177,7 +191,10 @@ export async function startPackService(
177
191
  token: config.token,
178
192
  ...(config.maxConcurrency !== undefined && { maxConcurrency: config.maxConcurrency }),
179
193
  ...(config.envCheck !== undefined && { envCheck: config.envCheck }),
180
- logger,
194
+ ...(legacy !== undefined && { logger: legacy }),
195
+ ...(records !== undefined && {
196
+ log: records.log.child({ packId: index.packId, artifactVersion: index.artifactVersion }),
197
+ }),
181
198
  });
182
199
  const failures = await service.prewarm();
183
200
  if (failures.length > 0) return { kind: 'err', problems: failures.map((f) => f.message) };
@@ -189,12 +206,9 @@ export async function startPackService(
189
206
  : server.listen(config.port, config.host, ready),
190
207
  );
191
208
  const port = (server.address() as AddressInfo).port;
192
- logger({
193
- kind: 'listening',
194
- port,
195
- packId: index.packId,
196
- artifactVersion: index.artifactVersion,
197
- });
209
+ const listening = { port, packId: index.packId, artifactVersion: index.artifactVersion };
210
+ legacy?.({ kind: 'listening', ...listening });
211
+ records?.event('listening', `Listening on port ${port}`, listening);
198
212
  return {
199
213
  kind: 'ok',
200
214
  value: {
@@ -219,34 +233,39 @@ export async function main(
219
233
  argv: readonly string[] = process.argv.slice(2),
220
234
  env: Readonly<Record<string, string | undefined>> = process.env,
221
235
  ): Promise<number> {
236
+ const built = packServiceLogs({ env, isTTY: process.stderr.isTTY === true });
237
+ if (built.kind === 'err') {
238
+ defaultPackServiceLogs().event('config-invalid', built.message, { problems: [built.message] });
239
+ return 1;
240
+ }
241
+ const logs = built.logs;
242
+ for (const problem of built.problems) logs.log.warn(problem);
222
243
  const config = readPackServiceConfig(argv, env);
223
244
  if (config.kind === 'err') {
224
- logJson({ kind: 'config-invalid', problems: config.problems });
245
+ logs.event('config-invalid', 'The pack service configuration is invalid', {
246
+ problems: config.problems,
247
+ });
225
248
  return 1;
226
249
  }
227
250
  // The token is for the service's callers. The pack's code, loaded
228
251
  // next, runs in this process and has no use for it — and a dependency
229
252
  // that read it could call the pack's tools around the runtime.
230
253
  Reflect.deleteProperty(process.env, 'KINDGI_PACK_SERVICE_TOKEN');
231
- const started = await startPackService(config.value);
254
+ const started = await startPackService(config.value, logs);
232
255
  if (started.kind === 'err') {
233
- logJson({ kind: 'boot-failed', problems: started.problems });
256
+ logs.event('boot-failed', 'The pack service failed to boot', { problems: started.problems });
234
257
  return 1;
235
258
  }
236
259
  await new Promise<void>((stopped) => {
237
260
  process.once('SIGTERM', () => {
238
- logJson({ kind: 'draining' });
261
+ logs.event('draining', 'Draining: finishing the calls in flight');
239
262
  void started.value.stop().then(stopped);
240
263
  });
241
264
  });
242
- logJson({ kind: 'stopped' });
265
+ logs.event('stopped', 'Stopped');
243
266
  return 0;
244
267
  }
245
268
 
246
- function logJson(event: PackServiceLogEvent | Record<string, unknown>): void {
247
- process.stderr.write(`${JSON.stringify(event)}\n`);
248
- }
249
-
250
269
  function describe(cause: unknown): string {
251
270
  return cause instanceof Error ? cause.message : String(cause);
252
271
  }