@kindgi/handler-runtime 0.1.4 → 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 (43) 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 +23 -2
  28. package/dist/pack-service/supervisor.d.ts.map +1 -1
  29. package/dist/pack-service/supervisor.js +50 -10
  30. package/dist/pack-service/supervisor.js.map +1 -1
  31. package/dist/protocol.d.ts +5 -0
  32. package/dist/protocol.d.ts.map +1 -1
  33. package/dist/protocol.js.map +1 -1
  34. package/package.json +9 -7
  35. package/src/handler-runner.ts +62 -4
  36. package/src/index.ts +6 -0
  37. package/src/kindgi-index.ts +192 -17
  38. package/src/pack-env.ts +3 -3
  39. package/src/pack-service/main.ts +39 -20
  40. package/src/pack-service/records.ts +99 -0
  41. package/src/pack-service/service.ts +96 -16
  42. package/src/pack-service/supervisor.ts +65 -13
  43. package/src/protocol.ts +5 -0
@@ -0,0 +1,99 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The pack service's log: `@kindgi/log` records on stderr, subsystem
6
+ * `pack` (the runtime writes the same schema). Two streams share the sink:
7
+ *
8
+ * - **`log`**: calls, and `ctx.log` beneath them (`pack.tool`,
9
+ * `pack.check`). `KINDGI_LOG_LEVEL` / `KINDGI_LOG_LEVELS` apply.
10
+ * - **`event`**: the lifecycle (`listening`, `boot-failed`, …), written
11
+ * whatever the levels, because a supervisor reads it to know the
12
+ * service is up. Its records carry the event's name as `event`, and as
13
+ * `kind` too, so a supervisor from before records (which reads bare
14
+ * `{"kind": …}` lines) still sees `listening`. `kind` stays through
15
+ * 0.1.x.
16
+ *
17
+ * The format is `KINDGI_LOG_FORMAT`'s, but `auto` is pretty only on a
18
+ * terminal: a supervisor pipes stderr, and dev mode (`KINDGI_DEV`) doesn't
19
+ * make it pretty, so a supervisor always gets JSON unless told otherwise.
20
+ */
21
+
22
+ import {
23
+ type LogFields,
24
+ type Logger,
25
+ createLogger,
26
+ loggerFromEnv,
27
+ resolveLogFormat,
28
+ } from '@kindgi/log';
29
+
30
+ /** Lifecycle events: always written, the supervisor's to read. */
31
+ export const PACK_SERVICE_EVENTS = [
32
+ 'listening',
33
+ 'boot-failed',
34
+ 'config-invalid',
35
+ 'draining',
36
+ 'stopped',
37
+ ] as const;
38
+ export type PackServiceEventKind = (typeof PACK_SERVICE_EVENTS)[number];
39
+
40
+ const EVENT_LEVEL = {
41
+ listening: 'info',
42
+ 'boot-failed': 'error',
43
+ 'config-invalid': 'error',
44
+ draining: 'info',
45
+ stopped: 'info',
46
+ } as const satisfies Record<PackServiceEventKind, 'info' | 'error'>;
47
+
48
+ export interface PackServiceLogs {
49
+ /** Calls, and `ctx.log` beneath them; the levels apply. */
50
+ readonly log: Logger;
51
+ /** A lifecycle event, written whatever the levels. */
52
+ event(kind: PackServiceEventKind, message: string, fields?: LogFields): void;
53
+ }
54
+
55
+ export type PackServiceLogsOutcome =
56
+ | { readonly kind: 'ok'; readonly logs: PackServiceLogs; readonly problems: readonly string[] }
57
+ | { readonly kind: 'err'; readonly message: string };
58
+
59
+ /** The pack service's logs from `KINDGI_LOG_*`; a bad setting is an error naming it. */
60
+ export function packServiceLogs(input: {
61
+ readonly env: Readonly<Record<string, string | undefined>>;
62
+ /** One line, no newline. Default stderr. */
63
+ readonly write?: (line: string) => void;
64
+ readonly isTTY?: boolean;
65
+ }): PackServiceLogsOutcome {
66
+ // Dev mode doesn't make the service's records pretty: its supervisor reads them.
67
+ const { KINDGI_DEV: _dev, ...env } = input.env;
68
+ const write = input.write ?? ((line: string) => void process.stderr.write(`${line}\n`));
69
+ const isTTY = input.isTTY ?? false;
70
+ // No unknown-subsystem check: pack code logs under its own names too
71
+ // (`kindgi.log.get_logger("billing")`, its own `@kindgi/log` loggers).
72
+ const built = loggerFromEnv({ env, write, isTTY });
73
+ if (built.kind === 'err') return built;
74
+ const format = resolveLogFormat(env.KINDGI_LOG_FORMAT, { isTTY }) ?? 'json';
75
+ const always = createLogger({
76
+ level: 'trace',
77
+ format,
78
+ color: format === 'pretty' && isTTY && (env.NO_COLOR ?? '') === '',
79
+ write,
80
+ subsystem: 'pack',
81
+ });
82
+ return {
83
+ kind: 'ok',
84
+ problems: built.problems,
85
+ logs: {
86
+ log: built.logger.child({ subsystem: 'pack' }),
87
+ event(kind, message, fields = {}) {
88
+ always[EVENT_LEVEL[kind]](message, { ...fields, event: kind, kind });
89
+ },
90
+ },
91
+ };
92
+ }
93
+
94
+ /** Logs for a caller that passes none: records on stderr, at the defaults. */
95
+ export function defaultPackServiceLogs(): PackServiceLogs {
96
+ const built = packServiceLogs({ env: {}, isTTY: false });
97
+ if (built.kind === 'err') throw new Error(built.message);
98
+ return built.logs;
99
+ }
@@ -29,7 +29,9 @@ import { timingSafeEqual } from 'node:crypto';
29
29
  import type { IncomingMessage, ServerResponse } from 'node:http';
30
30
  import { pathToFileURL } from 'node:url';
31
31
 
32
- import type { HandlerError } from '../handler-runner.js';
32
+ import { type Logger, childSpan, noopLogger, parseTraceparent } from '@kindgi/log';
33
+
34
+ import type { HandlerContext, HandlerError } from '../handler-runner.js';
33
35
  import { runCheck, runHandler } from '../handler-runner.js';
34
36
  import type { Index, IndexedGuardrail, IndexedTool } from '../kindgi-index.js';
35
37
  import { type PackEnvCheck, missingPackEnv } from '../pack-env.js';
@@ -37,6 +39,7 @@ import {
37
39
  type CheckInvokeMessage,
38
40
  PACK_HEADERS,
39
41
  PACK_PROTOCOL_VERSION,
42
+ type PackCallContext,
40
43
  type PackErrorCode,
41
44
  type PackErrorMessage,
42
45
  type PackResponse,
@@ -59,6 +62,12 @@ export interface PackServiceOptions {
59
62
  /** Deadline when a call sends no `kindgi-timeout-ms`. Default 120 s. */
60
63
  readonly defaultTimeoutMs?: number;
61
64
  readonly logger?: (event: PackServiceLogEvent) => void;
65
+ /**
66
+ * Where the service's records go (`@kindgi/log`, subsystem `pack`): a
67
+ * record per call, with the call's ids and trace, and each handler's
68
+ * `ctx.log` beneath it. Default: none. `logger` still gets its events.
69
+ */
70
+ readonly log?: Logger;
62
71
  /**
63
72
  * The process environment the index's `env.required` names are checked
64
73
  * against, once, at creation. Default `process.env`.
@@ -115,6 +124,7 @@ export function createPackService(options: PackServiceOptions): PackService {
115
124
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
116
125
  const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
117
126
  const logger = options.logger ?? (() => undefined);
127
+ const log = options.log ?? noopLogger;
118
128
  const importModule =
119
129
  options.importModule ?? ((p: string) => import(pathToFileURL(p).href) as Promise<unknown>);
120
130
  const expectedToken = Buffer.from(options.token);
@@ -138,7 +148,13 @@ export function createPackService(options: PackServiceOptions): PackService {
138
148
 
139
149
  const envCheck = options.envCheck ?? 'strict';
140
150
  const missingEnv = missingPackEnv(options.index.env, options.env ?? process.env);
141
- if (missingEnv.length > 0) logger({ kind: 'missing-env', check: envCheck, names: missingEnv });
151
+ if (missingEnv.length > 0) {
152
+ logger({ kind: 'missing-env', check: envCheck, names: missingEnv });
153
+ log.warn(
154
+ `The pack's env.required ${missingEnv.length === 1 ? 'name has' : 'names have'} no value: ${missingEnv.join(', ')}`,
155
+ { event: 'missing-env', kind: 'missing-env', check: envCheck, names: missingEnv },
156
+ );
157
+ }
142
158
 
143
159
  /** Why the service can't take calls now, or `undefined` when it can. */
144
160
  function notReady(): Reply | undefined {
@@ -239,7 +255,11 @@ export function createPackService(options: PackServiceOptions): PackService {
239
255
  };
240
256
  }
241
257
 
242
- function callTool(message: ToolInvokeMessage, signal: AbortSignal): Promise<PackResponse> {
258
+ function callTool(
259
+ message: ToolInvokeMessage,
260
+ signal: AbortSignal,
261
+ callLog: Logger,
262
+ ): Promise<PackResponse> {
243
263
  const found = toolFor(message.tool);
244
264
  if ('refused' in found) return Promise.resolve(found.refused);
245
265
  const { tool } = found;
@@ -251,7 +271,7 @@ export function createPackService(options: PackServiceOptions): PackService {
251
271
  outputSchema: tool.output,
252
272
  },
253
273
  input: message.input,
254
- ctx: { ...message.ctx, abortSignal: signal },
274
+ ctx: handlerContext(message.ctx, signal, callLog),
255
275
  importHandler: (p) => importModule(p) as never,
256
276
  }).then((r) =>
257
277
  r.kind === 'ok'
@@ -270,7 +290,11 @@ export function createPackService(options: PackServiceOptions): PackService {
270
290
  );
271
291
  }
272
292
  return runCheck({
273
- check: { id: message.check.id, modulePath: options.resolveModule(guardrail.checkModulePath) },
293
+ check: {
294
+ id: message.check.id,
295
+ modulePath: options.resolveModule(guardrail.checkModulePath),
296
+ ...(guardrail.configSchema !== undefined && { configSchema: guardrail.configSchema }),
297
+ },
274
298
  config: message.config,
275
299
  trace: message.trace,
276
300
  abortSignal: signal,
@@ -349,24 +373,38 @@ export function createPackService(options: PackServiceOptions): PackService {
349
373
  });
350
374
 
351
375
  const started = Date.now();
376
+ // The call's ids and trace, on its record and on the handler's `ctx.log`.
377
+ const call = log.child({
378
+ ...callIds(message.kind === 'invoke' ? message.ctx : undefined),
379
+ ...callTrace(req.headers[PACK_HEADERS.traceparent]),
380
+ target,
381
+ ...(message.kind === 'invoke' ? { toolId: id } : { checkId: id }),
382
+ });
352
383
  const work =
353
384
  message.kind === 'invoke'
354
- ? callTool(message, controller.signal)
385
+ ? callTool(message, controller.signal, call.child({ subsystem: `pack.${target}` }))
355
386
  : callCheck(message, controller.signal);
356
387
  const outcome = await Promise.race([work, aborted(controller.signal, id, target)]);
357
388
  clearTimeout(timer);
358
389
  if (controller.signal.aborted) {
359
- void work.then(() =>
360
- logger({ kind: 'handler-finished-late', target, id, afterMs: Date.now() - started }),
361
- );
390
+ void work.then(() => {
391
+ const afterMs = Date.now() - started;
392
+ logger({ kind: 'handler-finished-late', target, id, afterMs });
393
+ call.warn(`${target} ${id} finished ${afterMs} ms after its call ended`, {
394
+ event: 'handler-finished-late',
395
+ kind: 'handler-finished-late',
396
+ afterMs,
397
+ });
398
+ });
362
399
  }
363
- logger({
364
- kind: 'call',
365
- target,
366
- id,
367
- durationMs: Date.now() - started,
368
- outcome: outcome.kind === 'error' ? outcome.code : 'ok',
369
- });
400
+ const durationMs = Date.now() - started;
401
+ const result = outcome.kind === 'error' ? outcome.code : 'ok';
402
+ logger({ kind: 'call', target, id, durationMs, outcome: result });
403
+ call[result === 'ok' ? 'info' : 'warn'](
404
+ `${target} ${id} ${result} ${durationMs}ms`,
405
+ { event: 'call', kind: 'call', id, outcome: result, durationMs },
406
+ { inMessage: ['target', 'id', 'outcome', 'durationMs'] },
407
+ );
370
408
  return outcome;
371
409
  }
372
410
 
@@ -509,3 +547,45 @@ function describe(cause: unknown): string {
509
547
  function toolKey(id: string, version: string | undefined): string {
510
548
  return version === undefined ? id : `${id}@${version}`;
511
549
  }
550
+
551
+ /**
552
+ * The context a handler gets: the call's own (`PackCallContext`) and its
553
+ * abort signal, with `log` and `secrets` there but not enumerable, so
554
+ * printing, spreading or serializing a context never shows a secret.
555
+ */
556
+ function handlerContext(wire: PackCallContext, signal: AbortSignal, log: Logger): HandlerContext {
557
+ const { secrets, ...rest } = wire;
558
+ const ctx = { ...rest, abortSignal: signal };
559
+ if (secrets !== undefined) {
560
+ Object.defineProperty(ctx, 'secrets', { value: secrets, enumerable: false });
561
+ }
562
+ Object.defineProperty(ctx, 'log', { value: log, enumerable: false });
563
+ return ctx as HandlerContext;
564
+ }
565
+
566
+ /** The ids a call's records carry, from its context. */
567
+ function callIds(ctx: PackCallContext | undefined): Record<string, string> {
568
+ if (ctx === undefined) return {};
569
+ const ids: Record<string, string> = {};
570
+ for (const key of ['tenantId', 'projectId', 'orgId', 'runId', 'requestId'] as const) {
571
+ const value: unknown = ctx[key];
572
+ if (typeof value === 'string' && value !== '') ids[key] = value;
573
+ }
574
+ return ids;
575
+ }
576
+
577
+ /**
578
+ * The call's trace: the caller's trace id, and a span of this call's own
579
+ * whose parent is the caller's. None when the caller sent no
580
+ * `traceparent` (an older runtime): a fresh trace would join nothing.
581
+ */
582
+ function callTrace(header: string | string[] | undefined): Record<string, string> {
583
+ const incoming = parseTraceparent(Array.isArray(header) ? header[0] : header);
584
+ if (incoming === undefined) return {};
585
+ const span = childSpan({
586
+ traceId: incoming.traceId,
587
+ spanId: incoming.parentSpanId,
588
+ flags: incoming.flags,
589
+ });
590
+ return { traceId: span.traceId, spanId: span.spanId };
591
+ }
@@ -50,6 +50,7 @@ export interface PackServiceSupervisorOptions {
50
50
  *
51
51
  * - Node: `[process.execPath, '--enable-source-maps', <@kindgi/handler-runtime/pack-service-main>]`
52
52
  * - Python: `[<the pack's python>, '-m', 'kindgi.pack', 'serve']`
53
+ * - Java and Scala: `['sh', <kindgi-pack-java>, '-cp', <classpath> (or `@<argfile>`), 'com.kindgi.pack.Main', 'serve']`
53
54
  */
54
55
  readonly command: readonly [string, ...string[]];
55
56
  /** Where the index's module paths resolve — the pack directory. */
@@ -102,8 +103,16 @@ export type PackServiceSupervisorEvent =
102
103
  | { readonly kind: 'exited'; readonly code: number | null; readonly signal: string | null }
103
104
  | { readonly kind: 'restarting'; readonly attempt: number }
104
105
  | { readonly kind: 'gave-up'; readonly attempts: number }
105
- /** One of the service's own JSON log lines (a call, draining, …). */
106
- | { readonly kind: 'log'; readonly event: Readonly<Record<string, unknown>> };
106
+ /**
107
+ * A record the service wrote (a call, draining, an author's `ctx.log`
108
+ * line, …), or a bare event from an older service: `event` as
109
+ * `serviceEvent` reads it, `line` as written.
110
+ */
111
+ | {
112
+ readonly kind: 'log';
113
+ readonly event: Readonly<Record<string, unknown>>;
114
+ readonly line: string;
115
+ };
107
116
 
108
117
  export interface BootFailure {
109
118
  readonly problems: readonly string[];
@@ -122,7 +131,7 @@ export interface PackRelayCall {
122
131
  readonly requestId?: string;
123
132
  /** The caller's protocol version (`kindgi-protocol`), passed on as is. */
124
133
  readonly protocol?: string;
125
- /** The caller's W3C trace context (`traceparent`), passed on as is. */
134
+ /** The caller's W3C trace context (`traceparent`), passed on as is, so the child's records carry its trace. */
126
135
  readonly traceparent?: string;
127
136
  }
128
137
 
@@ -225,7 +234,13 @@ export function createPackServiceSupervisor(
225
234
  let front: { readonly server: Server; readonly url: string; readonly port: number } | undefined;
226
235
 
227
236
  async function boot(indexPath: string): Promise<Result<Child, BootFailure>> {
228
- const env = { ...(await options.env()), KINDGI_PACK_SERVICE_TOKEN: token, PORT: '0' };
237
+ // JSON whatever the pack's env says: this process reads the child's records.
238
+ const env = {
239
+ ...(await options.env()),
240
+ KINDGI_PACK_SERVICE_TOKEN: token,
241
+ PORT: '0',
242
+ KINDGI_LOG_FORMAT: 'json',
243
+ };
229
244
  const [program, ...args] = options.command;
230
245
  const child = spawn(
231
246
  program,
@@ -265,7 +280,7 @@ export function createPackServiceSupervisor(
265
280
  } else if (event.kind === 'boot-failed' || event.kind === 'config-invalid') {
266
281
  problems.push(...problemsOf(event));
267
282
  } else {
268
- emit({ kind: 'log', event });
283
+ emit({ kind: 'log', event, line });
269
284
  }
270
285
  });
271
286
  child.once('error', (cause) => fail([cause.message]));
@@ -625,21 +640,58 @@ function problemsOf(event: Readonly<Record<string, unknown>>): readonly string[]
625
640
  return Array.isArray(event.problems) ? event.problems.map(String) : [];
626
641
  }
627
642
 
628
- /** The service's own log lines are JSON objects with a `kind`. */
629
- function serviceEvent(
643
+ /** What a pack service from before records wrote as bare `{"kind": …}` lines. */
644
+ const LEGACY_EVENTS: ReadonlySet<string> = new Set([
645
+ 'listening',
646
+ 'boot-failed',
647
+ 'config-invalid',
648
+ 'missing-env',
649
+ 'draining',
650
+ 'stopped',
651
+ 'call',
652
+ 'handler-finished-late',
653
+ ]);
654
+
655
+ /**
656
+ * One of the service's own log lines, as an event with a `kind`:
657
+ *
658
+ * - a record (`@kindgi/log`: `time`, `level`, `subsystem`, `message`).
659
+ * The service's own, subsystem `pack`, carry their event as `event`
660
+ * (`listening`, `call`, …), which becomes `kind`. Any other record, an
661
+ * author's `ctx.log` (`pack.tool`) say, comes back with `kind: 'record'`,
662
+ * to show, never to act on;
663
+ * - a bare `{"kind": …}` line from a pack service from before records,
664
+ * for the events it wrote. Any other JSON line is the pack's own
665
+ * output, shown as it is.
666
+ */
667
+ export function serviceEvent(
630
668
  line: string,
631
669
  ): (Record<string, unknown> & { readonly kind: string }) | undefined {
632
670
  if (!line.startsWith('{')) return undefined;
671
+ let parsed: unknown;
633
672
  try {
634
- const parsed = JSON.parse(line) as unknown;
635
- return parsed !== null &&
636
- typeof parsed === 'object' &&
637
- typeof (parsed as { kind?: unknown }).kind === 'string'
638
- ? (parsed as Record<string, unknown> & { readonly kind: string })
639
- : undefined;
673
+ parsed = JSON.parse(line);
640
674
  } catch {
641
675
  return undefined;
642
676
  }
677
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
678
+ const fields = parsed as Record<string, unknown>;
679
+ if (isRecord(fields)) {
680
+ const own = fields.subsystem === 'pack' && typeof fields.event === 'string';
681
+ return { ...fields, kind: own ? (fields.event as string) : 'record' };
682
+ }
683
+ return typeof fields.kind === 'string' && LEGACY_EVENTS.has(fields.kind)
684
+ ? (fields as Record<string, unknown> & { readonly kind: string })
685
+ : undefined;
686
+ }
687
+
688
+ function isRecord(fields: Record<string, unknown>): boolean {
689
+ return (
690
+ typeof fields.time === 'string' &&
691
+ typeof fields.level === 'string' &&
692
+ typeof fields.subsystem === 'string' &&
693
+ typeof fields.message === 'string'
694
+ );
643
695
  }
644
696
 
645
697
  function describe(cause: unknown): string {
package/src/protocol.ts CHANGED
@@ -32,8 +32,13 @@ export interface PackCallContext {
32
32
  readonly orgId?: string;
33
33
  /** The individual call — e.g. the model's tool-call id. */
34
34
  readonly requestId?: string;
35
+ /**
36
+ * The env values the tool declares (`needsSpec.env`), resolved for this
37
+ * call: project, else org, else tenant (protocol 2.5.0). Strings.
38
+ */
35
39
  readonly env?: Readonly<Record<string, unknown>>;
36
40
  readonly secrets?: Readonly<Record<string, unknown>>;
41
+ /** Reserved: no runtime sends it yet. */
37
42
  readonly config?: Readonly<Record<string, unknown>>;
38
43
  /** The calling agent version's settings blocks' values, by block id (protocol 2.4.0). */
39
44
  readonly settings?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;