@agentuity/runtime 0.0.67 → 0.0.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  } from '@agentuity/core';
11
11
  import { context, SpanStatusCode, type Tracer, trace } from '@opentelemetry/api';
12
12
  import type { Context, MiddlewareHandler } from 'hono';
13
+ import type { Handler } from 'hono/types';
14
+ import { validator } from 'hono/validator';
13
15
  import { getAgentContext, runInAgentContext, type RequestAgentContextArgs } from './_context';
14
16
  import type { Logger } from './logger';
15
17
  import type {
@@ -29,6 +31,7 @@ import { getEvalRunEventProvider } from './_services';
29
31
  import * as runtimeConfig from './_config';
30
32
  import type { EvalRunStartEvent } from '@agentuity/core';
31
33
  import type { AppState } from './index';
34
+ import { validateSchema, formatValidationIssues } from './_validation';
32
35
 
33
36
  export type AgentEventName = 'started' | 'completed' | 'errored';
34
37
 
@@ -394,6 +397,147 @@ type CreateEvalMethod<
394
397
  TOutput extends StandardSchemaV1 | undefined = any,
395
398
  > = (config: CreateEvalConfig<TInput, TOutput>) => Eval<TInput, TOutput>;
396
399
 
400
+ /**
401
+ * Validator function type with method overloads for different validation scenarios.
402
+ * Provides type-safe validation middleware that integrates with Hono's type system.
403
+ *
404
+ * This validator automatically validates incoming JSON request bodies using StandardSchema-compatible
405
+ * schemas (Zod, Valibot, ArkType, etc.) and provides full TypeScript type inference for validated data
406
+ * accessible via `c.req.valid('json')`.
407
+ *
408
+ * The validator returns 400 Bad Request with validation error details if validation fails.
409
+ *
410
+ * @template TInput - Agent's input schema type (StandardSchemaV1 or undefined)
411
+ * @template _TOutput - Agent's output schema type (reserved for future output validation)
412
+ *
413
+ * @example Basic usage with agent's schema
414
+ * ```typescript
415
+ * router.post('/', agent.validator(), async (c) => {
416
+ * const data = c.req.valid('json'); // Fully typed from agent's input schema
417
+ * return c.json(data);
418
+ * });
419
+ * ```
420
+ *
421
+ * @example Override with custom input schema
422
+ * ```typescript
423
+ * router.post('/custom', agent.validator({ input: z.object({ id: z.string() }) }), async (c) => {
424
+ * const data = c.req.valid('json'); // Typed as { id: string }
425
+ * return c.json(data);
426
+ * });
427
+ * ```
428
+ */
429
+ export interface AgentValidator<
430
+ TInput extends StandardSchemaV1 | undefined,
431
+ _TOutput extends StandardSchemaV1 | undefined,
432
+ > {
433
+ /**
434
+ * Validates using the agent's input schema (no override).
435
+ * Returns Hono middleware handler that validates JSON request body.
436
+ *
437
+ * @returns Middleware handler with type inference for validated data
438
+ *
439
+ * @example
440
+ * ```typescript
441
+ * // Agent has schema: { input: z.object({ name: z.string() }) }
442
+ * router.post('/', agent.validator(), async (c) => {
443
+ * const data = c.req.valid('json'); // { name: string }
444
+ * return c.json({ received: data.name });
445
+ * });
446
+ * ```
447
+ */
448
+ (): TInput extends StandardSchemaV1
449
+ ? Handler<
450
+ any,
451
+ any,
452
+ {
453
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
454
+ in: {};
455
+ out: { json: StandardSchemaV1.InferOutput<TInput> };
456
+ }
457
+ >
458
+ : Handler<any, any, any>;
459
+
460
+ /**
461
+ * Output-only validation override.
462
+ * Validates only the response body (no input validation).
463
+ *
464
+ * Useful for GET routes or routes where input validation is handled elsewhere.
465
+ * The middleware validates the JSON response body and throws 500 Internal Server Error
466
+ * if validation fails.
467
+ *
468
+ * @template TOverrideOutput - Custom output schema type
469
+ * @param override - Object containing output schema
470
+ * @returns Middleware handler that validates response output
471
+ *
472
+ * @example GET route with output validation
473
+ * ```typescript
474
+ * router.get('/', agent.validator({ output: z.array(z.object({ id: z.string() })) }), async (c) => {
475
+ * // Returns array of objects - validated against schema
476
+ * return c.json([{ id: '123' }, { id: '456' }]);
477
+ * });
478
+ * ```
479
+ */
480
+ <TOverrideOutput extends StandardSchemaV1>(override: {
481
+ output: TOverrideOutput;
482
+ }): Handler<
483
+ any,
484
+ any,
485
+ {
486
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
487
+ in: {};
488
+ out: { json: StandardSchemaV1.InferOutput<TOverrideOutput> };
489
+ }
490
+ >;
491
+
492
+ /**
493
+ * Validates with custom input and optional output schemas (POST/PUT/PATCH/DELETE).
494
+ * Overrides the agent's schema for this specific route.
495
+ *
496
+ * @template TOverrideInput - Custom input schema type
497
+ * @template TOverrideOutput - Optional custom output schema type
498
+ * @param override - Object containing input (required) and output (optional) schemas
499
+ * @returns Middleware handler with type inference from custom schemas
500
+ *
501
+ * @example Custom input schema
502
+ * ```typescript
503
+ * router.post('/users', agent.validator({
504
+ * input: z.object({ email: z.string().email(), name: z.string() })
505
+ * }), async (c) => {
506
+ * const data = c.req.valid('json'); // { email: string, name: string }
507
+ * return c.json({ id: '123', ...data });
508
+ * });
509
+ * ```
510
+ *
511
+ * @example Custom input and output schemas
512
+ * ```typescript
513
+ * router.post('/convert', agent.validator({
514
+ * input: z.string(),
515
+ * output: z.number()
516
+ * }), async (c) => {
517
+ * const data = c.req.valid('json'); // string
518
+ * return c.json(123);
519
+ * });
520
+ * ```
521
+ */
522
+ <
523
+ TOverrideInput extends StandardSchemaV1,
524
+ TOverrideOutput extends StandardSchemaV1 | undefined = undefined,
525
+ >(override: {
526
+ input: TOverrideInput;
527
+ output?: TOverrideOutput;
528
+ }): Handler<
529
+ any,
530
+ any,
531
+ {
532
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
533
+ in: {};
534
+ out: {
535
+ json: StandardSchemaV1.InferOutput<TOverrideInput>;
536
+ };
537
+ }
538
+ >;
539
+ }
540
+
397
541
  /**
398
542
  * Agent instance type returned by createAgent().
399
543
  * Represents a fully configured agent with metadata, handler, lifecycle hooks, and event listeners.
@@ -449,6 +593,76 @@ export type Agent<
449
593
  ...args: any[]
450
594
  ) => any | Promise<any>;
451
595
 
596
+ /**
597
+ * Creates a type-safe validation middleware for routes using StandardSchema validation.
598
+ *
599
+ * This method validates incoming JSON request bodies against the agent's **input schema**
600
+ * and optionally validates outgoing JSON responses against the **output schema**.
601
+ * Provides full TypeScript type inference for validated input data accessible via `c.req.valid('json')`.
602
+ *
603
+ * **Validation behavior:**
604
+ * - **Input**: Validates request JSON body, returns 400 Bad Request on failure
605
+ * - **Output**: Validates response JSON body (if output schema provided), throws 500 on failure
606
+ * - Passes validated input data to handler via `c.req.valid('json')`
607
+ * - Full TypeScript type inference for validated input data
608
+ *
609
+ * **Supported schema libraries:**
610
+ * - Zod (z.object, z.string, etc.)
611
+ * - Valibot (v.object, v.string, etc.)
612
+ * - ArkType (type({ ... }))
613
+ * - Any StandardSchema-compatible library
614
+ *
615
+ * **Method overloads:**
616
+ * - `agent.validator()` - Validates using agent's input/output schemas
617
+ * - `agent.validator({ output: schema })` - Output-only validation (no input validation)
618
+ * - `agent.validator({ input: schema })` - Custom input schema override
619
+ * - `agent.validator({ input: schema, output: schema })` - Both input and output validated
620
+ *
621
+ * @returns Hono middleware handler with proper type inference
622
+ *
623
+ * @example Automatic validation using agent's schema
624
+ * ```typescript
625
+ * // Agent defined with: schema: { input: z.object({ name: z.string(), age: z.number() }) }
626
+ * router.post('/', agent.validator(), async (c) => {
627
+ * const data = c.req.valid('json'); // Fully typed: { name: string, age: number }
628
+ * return c.json({ greeting: `Hello ${data.name}, age ${data.age}` });
629
+ * });
630
+ * ```
631
+ *
632
+ * @example Override with custom schema per-route
633
+ * ```typescript
634
+ * router.post('/email', agent.validator({
635
+ * input: z.object({ email: z.string().email() })
636
+ * }), async (c) => {
637
+ * const data = c.req.valid('json'); // Typed as { email: string }
638
+ * return c.json({ sent: data.email });
639
+ * });
640
+ * ```
641
+ *
642
+ * @example Works with any StandardSchema library
643
+ * ```typescript
644
+ * import * as v from 'valibot';
645
+ *
646
+ * router.post('/valibot', agent.validator({
647
+ * input: v.object({ count: v.number() })
648
+ * }), async (c) => {
649
+ * const data = c.req.valid('json'); // Typed correctly
650
+ * return c.json({ count: data.count });
651
+ * });
652
+ * ```
653
+ *
654
+ * @example Validation error response (400)
655
+ * ```typescript
656
+ * // Request: { "name": "Bob" } (missing 'age')
657
+ * // Response: {
658
+ * // "error": "Validation failed",
659
+ * // "message": "age: Invalid input: expected number, received undefined",
660
+ * // "issues": [{ "message": "...", "path": ["age"] }]
661
+ * // }
662
+ * ```
663
+ */
664
+ validator: AgentValidator<TInput, TOutput>;
665
+
452
666
  /**
453
667
  * Array of evaluation functions created via agent.createEval().
454
668
  * Used for testing and validating agent behavior.
@@ -1536,6 +1750,108 @@ export function createAgent<
1536
1750
  agent.stream = config.schema.stream;
1537
1751
  }
1538
1752
 
1753
+ // Add validator method with overloads
1754
+ agent.validator = ((override?: any) => {
1755
+ const effectiveInputSchema = override?.input ?? inputSchema;
1756
+ const effectiveOutputSchema = override?.output ?? outputSchema;
1757
+
1758
+ // Helper to build the standard Hono input validator so types flow
1759
+ const buildInputValidator = (schema?: StandardSchemaV1) =>
1760
+ validator('json', async (value, c) => {
1761
+ if (schema) {
1762
+ const result = await validateSchema(schema, value);
1763
+ if (!result.success) {
1764
+ return c.json(
1765
+ {
1766
+ error: 'Validation failed',
1767
+ message: formatValidationIssues(result.issues),
1768
+ issues: result.issues,
1769
+ },
1770
+ 400
1771
+ );
1772
+ }
1773
+ return result.data;
1774
+ }
1775
+ return value;
1776
+ });
1777
+
1778
+ // If no output schema, preserve existing behavior: pure input validation
1779
+ if (!effectiveOutputSchema) {
1780
+ return buildInputValidator(effectiveInputSchema);
1781
+ }
1782
+
1783
+ // Output validation middleware (runs after handler)
1784
+ const outputValidator: MiddlewareHandler = async (c, next) => {
1785
+ await next();
1786
+
1787
+ const res = c.res;
1788
+ if (!res) return;
1789
+
1790
+ // Skip output validation for streaming agents
1791
+ if (config.schema?.stream) {
1792
+ return;
1793
+ }
1794
+
1795
+ // Only validate JSON responses
1796
+ const contentType = res.headers.get('Content-Type') ?? '';
1797
+ if (!contentType.toLowerCase().includes('application/json')) {
1798
+ return;
1799
+ }
1800
+
1801
+ // Clone so we don't consume the body that will be sent
1802
+ let responseBody: unknown;
1803
+ try {
1804
+ const cloned = res.clone();
1805
+ responseBody = await cloned.json();
1806
+ } catch {
1807
+ const OutputValidationError = StructuredError('OutputValidationError')<{
1808
+ issues: any[];
1809
+ }>();
1810
+ throw new OutputValidationError({
1811
+ message: 'Output validation failed: response is not valid JSON',
1812
+ issues: [],
1813
+ });
1814
+ }
1815
+
1816
+ const result = await validateSchema(effectiveOutputSchema, responseBody);
1817
+ if (!result.success) {
1818
+ const OutputValidationError = StructuredError('OutputValidationError')<{
1819
+ issues: any[];
1820
+ }>();
1821
+ throw new OutputValidationError({
1822
+ message: `Output validation failed: ${formatValidationIssues(result.issues)}`,
1823
+ issues: result.issues,
1824
+ });
1825
+ }
1826
+
1827
+ // Replace response with validated/sanitized JSON
1828
+ c.res = new Response(JSON.stringify(result.data), {
1829
+ status: res.status,
1830
+ headers: res.headers,
1831
+ });
1832
+ };
1833
+
1834
+ // If we have no input schema, we only do output validation
1835
+ if (!effectiveInputSchema) {
1836
+ return outputValidator as unknown as Handler;
1837
+ }
1838
+
1839
+ // Compose: input validator → output validator
1840
+ const inputMiddleware = buildInputValidator(effectiveInputSchema);
1841
+
1842
+ const composed: MiddlewareHandler = async (c, next) => {
1843
+ // Run the validator first; its next() runs the output validator,
1844
+ // whose next() runs the actual handler(s)
1845
+ const result = await inputMiddleware(c, async () => {
1846
+ await outputValidator(c, next);
1847
+ });
1848
+ // If inputMiddleware returned early (validation failed), return that response
1849
+ return result;
1850
+ };
1851
+
1852
+ return composed as unknown as Handler;
1853
+ }) as AgentValidator<TInput, TOutput>;
1854
+
1539
1855
  return agent as Agent<TInput, TOutput, TStream, TConfig, TAppState>;
1540
1856
  }
1541
1857
 
@@ -1601,9 +1917,13 @@ const createAgentRunner = <
1601
1917
 
1602
1918
  /**
1603
1919
  * Populate the agents object with all registered agents
1920
+ * Keys are converted to camelCase to match the generated TypeScript types
1604
1921
  */
1605
1922
  export const populateAgentsRegistry = (ctx: Context): any => {
1606
1923
  const agentsObj: any = {};
1924
+ // Track ownership of camelCase keys to detect collisions between different raw names
1925
+ const ownershipMap = new Map<string, string>();
1926
+ const childOwnershipMap = new Map<string, string>();
1607
1927
 
1608
1928
  // Build nested structure for agents and subagents
1609
1929
  for (const [name, agentFn] of agents) {
@@ -1616,25 +1936,94 @@ export const populateAgentsRegistry = (ctx: Context): any => {
1616
1936
  internal.warn(`Invalid subagent name format: "${name}". Expected "parent.child".`);
1617
1937
  continue;
1618
1938
  }
1619
- const parentName = parts[0];
1620
- const childName = parts[1];
1621
- if (parentName && childName) {
1622
- if (!agentsObj[parentName]) {
1623
- // Ensure parent exists
1624
- const parentAgent = agents.get(parentName);
1939
+ const rawParentName = parts[0];
1940
+ const rawChildName = parts[1];
1941
+ if (rawParentName && rawChildName) {
1942
+ // Convert parent name to camelCase for registry key
1943
+ const parentKey = toCamelCase(rawParentName);
1944
+
1945
+ // Validate parentKey is non-empty
1946
+ if (!parentKey) {
1947
+ internal.warn(
1948
+ `Agent name "${rawParentName}" converts to empty camelCase key. Skipping.`
1949
+ );
1950
+ continue;
1951
+ }
1952
+
1953
+ // Detect collision on parent key - check ownership
1954
+ const existingOwner = ownershipMap.get(parentKey);
1955
+ if (existingOwner && existingOwner !== rawParentName) {
1956
+ internal.error(
1957
+ `Agent registry collision: "${rawParentName}" conflicts with "${existingOwner}" (both map to camelCase key "${parentKey}")`
1958
+ );
1959
+ throw new Error(`Agent registry collision detected for key "${parentKey}"`);
1960
+ }
1961
+
1962
+ if (!agentsObj[parentKey]) {
1963
+ // Ensure parent exists - look up by raw name in agents map
1964
+ const parentAgent = agents.get(rawParentName);
1625
1965
  if (parentAgent) {
1626
- agentsObj[parentName] = createAgentRunner(parentAgent, ctx);
1966
+ agentsObj[parentKey] = createAgentRunner(parentAgent, ctx);
1967
+ // Record ownership
1968
+ ownershipMap.set(parentKey, rawParentName);
1627
1969
  }
1628
1970
  }
1971
+
1629
1972
  // Attach subagent to parent using camelCase property name
1630
- const camelChildName = toCamelCase(childName);
1631
- if (agentsObj[parentName]) {
1632
- agentsObj[parentName][camelChildName] = runner;
1973
+ const childKey = toCamelCase(rawChildName);
1974
+
1975
+ // Validate childKey is non-empty
1976
+ if (!childKey) {
1977
+ internal.warn(
1978
+ `Agent name "${rawChildName}" converts to empty camelCase key. Skipping subagent "${name}".`
1979
+ );
1980
+ continue;
1981
+ }
1982
+
1983
+ // Detect collision on child key - check ownership
1984
+ const childOwnershipKey = `${parentKey}.${childKey}`;
1985
+ const existingChildOwner = childOwnershipMap.get(childOwnershipKey);
1986
+ if (existingChildOwner && existingChildOwner !== name) {
1987
+ internal.error(
1988
+ `Agent registry collision: subagent "${name}" conflicts with "${existingChildOwner}" (both map to camelCase key "${childOwnershipKey}")`
1989
+ );
1990
+ throw new Error(
1991
+ `Agent registry collision detected for subagent key "${childOwnershipKey}"`
1992
+ );
1993
+ }
1994
+
1995
+ if (agentsObj[parentKey]) {
1996
+ if (agentsObj[parentKey][childKey] === undefined) {
1997
+ agentsObj[parentKey][childKey] = runner;
1998
+ // Record ownership
1999
+ childOwnershipMap.set(childOwnershipKey, name);
2000
+ }
1633
2001
  }
1634
2002
  }
1635
2003
  } else {
1636
- // Parent agent or standalone agent
1637
- agentsObj[name] = runner;
2004
+ // Parent agent or standalone agent - convert to camelCase for registry key
2005
+ const parentKey = toCamelCase(name);
2006
+
2007
+ // Validate parentKey is non-empty
2008
+ if (!parentKey) {
2009
+ internal.warn(`Agent name "${name}" converts to empty camelCase key. Skipping.`);
2010
+ continue;
2011
+ }
2012
+
2013
+ // Detect collision on parent key - check ownership
2014
+ const existingOwner = ownershipMap.get(parentKey);
2015
+ if (existingOwner && existingOwner !== name) {
2016
+ internal.error(
2017
+ `Agent registry collision: "${name}" conflicts with "${existingOwner}" (both map to camelCase key "${parentKey}")`
2018
+ );
2019
+ throw new Error(`Agent registry collision detected for key "${parentKey}"`);
2020
+ }
2021
+
2022
+ if (!agentsObj[parentKey]) {
2023
+ agentsObj[parentKey] = runner;
2024
+ // Record ownership
2025
+ ownershipMap.set(parentKey, name);
2026
+ }
1638
2027
  }
1639
2028
  }
1640
2029
 
@@ -1656,15 +2045,19 @@ export const createAgentMiddleware = (agentName: AgentName | ''): MiddlewareHand
1656
2045
  if (agentName?.includes('.')) {
1657
2046
  // This is a subagent
1658
2047
  const parts = agentName.split('.');
1659
- const parentName = parts[0];
1660
- const childName = parts[1];
1661
- if (parentName && childName) {
1662
- currentAgent = agentsObj[parentName]?.[childName];
1663
- parentAgent = agentsObj[parentName];
2048
+ const rawParentName = parts[0];
2049
+ const rawChildName = parts[1];
2050
+ if (rawParentName && rawChildName) {
2051
+ // Use camelCase keys to look up in agentsObj (which uses camelCase keys)
2052
+ const parentKey = toCamelCase(rawParentName);
2053
+ const childKey = toCamelCase(rawChildName);
2054
+ currentAgent = agentsObj[parentKey]?.[childKey];
2055
+ parentAgent = agentsObj[parentKey];
1664
2056
  }
1665
2057
  } else if (agentName) {
1666
- // This is a parent or standalone agent
1667
- currentAgent = agentsObj[agentName];
2058
+ // This is a parent or standalone agent - use camelCase key
2059
+ const parentKey = toCamelCase(agentName);
2060
+ currentAgent = agentsObj[parentKey];
1668
2061
  }
1669
2062
 
1670
2063
  const _ctx = privateContext(ctx);