nestjs-langchain 2.0.0 → 2.1.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 (89) hide show
  1. package/README.md +67 -5
  2. package/dist/agent.d.ts +16 -2
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +31 -23
  5. package/dist/agent.js.map +1 -1
  6. package/dist/constants.d.ts +2 -0
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/constants.js +6 -0
  9. package/dist/constants.js.map +1 -1
  10. package/dist/errors/index.d.ts +2 -0
  11. package/dist/errors/index.d.ts.map +1 -1
  12. package/dist/errors/index.js +2 -0
  13. package/dist/errors/index.js.map +1 -1
  14. package/dist/errors/messages.d.ts +7 -0
  15. package/dist/errors/messages.d.ts.map +1 -1
  16. package/dist/errors/messages.js +18 -3
  17. package/dist/errors/messages.js.map +1 -1
  18. package/dist/errors/structured-output.error.d.ts +22 -0
  19. package/dist/errors/structured-output.error.d.ts.map +1 -0
  20. package/dist/errors/structured-output.error.js +23 -0
  21. package/dist/errors/structured-output.error.js.map +1 -0
  22. package/dist/errors/unwrap-middleware-error.util.d.ts +10 -0
  23. package/dist/errors/unwrap-middleware-error.util.d.ts.map +1 -0
  24. package/dist/errors/unwrap-middleware-error.util.js +17 -0
  25. package/dist/errors/unwrap-middleware-error.util.js.map +1 -0
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/interfaces/completed-run.interface.d.ts +1 -1
  31. package/dist/interfaces/completed-run.interface.d.ts.map +1 -1
  32. package/dist/interfaces/run-options.interface.d.ts +21 -0
  33. package/dist/interfaces/run-options.interface.d.ts.map +1 -0
  34. package/dist/interfaces/run-options.interface.js +2 -0
  35. package/dist/interfaces/run-options.interface.js.map +1 -0
  36. package/dist/output/index.d.ts +3 -0
  37. package/dist/output/index.d.ts.map +1 -0
  38. package/dist/output/index.js +3 -0
  39. package/dist/output/index.js.map +1 -0
  40. package/dist/output/run-output.d.ts +43 -0
  41. package/dist/output/run-output.d.ts.map +1 -0
  42. package/dist/output/run-output.js +107 -0
  43. package/dist/output/run-output.js.map +1 -0
  44. package/dist/output/structured-output.middleware.d.ts +22 -0
  45. package/dist/output/structured-output.middleware.d.ts.map +1 -0
  46. package/dist/output/structured-output.middleware.js +40 -0
  47. package/dist/output/structured-output.middleware.js.map +1 -0
  48. package/dist/run/answered-calls.util.d.ts +10 -0
  49. package/dist/run/answered-calls.util.d.ts.map +1 -0
  50. package/dist/run/answered-calls.util.js +12 -0
  51. package/dist/run/answered-calls.util.js.map +1 -0
  52. package/dist/run/index.d.ts +2 -0
  53. package/dist/run/index.d.ts.map +1 -1
  54. package/dist/run/index.js +2 -0
  55. package/dist/run/index.js.map +1 -1
  56. package/dist/run/run-summary.util.d.ts +10 -3
  57. package/dist/run/run-summary.util.d.ts.map +1 -1
  58. package/dist/run/run-summary.util.js +18 -6
  59. package/dist/run/run-summary.util.js.map +1 -1
  60. package/dist/run/usage-aside.d.ts +23 -0
  61. package/dist/run/usage-aside.d.ts.map +1 -0
  62. package/dist/run/usage-aside.js +33 -0
  63. package/dist/run/usage-aside.js.map +1 -0
  64. package/dist/tools/tool-errors.middleware.d.ts +7 -1
  65. package/dist/tools/tool-errors.middleware.d.ts.map +1 -1
  66. package/dist/tools/tool-errors.middleware.js +81 -16
  67. package/dist/tools/tool-errors.middleware.js.map +1 -1
  68. package/dist/tools/tool-name.util.d.ts.map +1 -1
  69. package/dist/tools/tool-name.util.js +1 -3
  70. package/dist/tools/tool-name.util.js.map +1 -1
  71. package/lib/agent.ts +61 -15
  72. package/lib/constants.ts +8 -0
  73. package/lib/errors/index.ts +2 -0
  74. package/lib/errors/messages.ts +43 -3
  75. package/lib/errors/structured-output.error.ts +23 -0
  76. package/lib/errors/unwrap-middleware-error.util.ts +19 -0
  77. package/lib/index.ts +6 -1
  78. package/lib/interfaces/completed-run.interface.ts +1 -1
  79. package/lib/interfaces/run-options.interface.ts +21 -0
  80. package/lib/output/index.ts +5 -0
  81. package/lib/output/run-output.ts +154 -0
  82. package/lib/output/structured-output.middleware.ts +44 -0
  83. package/lib/run/answered-calls.util.ts +16 -0
  84. package/lib/run/index.ts +2 -0
  85. package/lib/run/run-summary.util.ts +33 -11
  86. package/lib/run/usage-aside.ts +41 -0
  87. package/lib/tools/tool-errors.middleware.ts +104 -10
  88. package/lib/tools/tool-name.util.ts +1 -4
  89. package/package.json +28 -9
@@ -1,10 +1,8 @@
1
+ import { STRUCTURED_OUTPUT_PREFIX } from '../constants.js';
1
2
  import { duplicateToolName, invalidToolName, reservedToolName, } from '../errors/messages.js';
2
3
  // OpenAI's limit, the strictest published, restated: no provider SDK exports
3
4
  // it. See #90.
4
5
  const TOOL_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
5
- // The agent routes a call with this prefix to structured output, never to the
6
- // tool. Restated: LangChain does not export it. See #213.
7
- const STRUCTURED_OUTPUT_PREFIX = 'extract-';
8
6
  export const resolveToolName = (declared, method, where) => {
9
7
  const name = declared ?? method;
10
8
  if (!TOOL_NAME.test(name)) {
@@ -1 +1 @@
1
- {"version":3,"file":"tool-name.util.js","sourceRoot":"","sources":["../../lib/tools/tool-name.util.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,gBAAgB,GACjB,MAAM,uBAAuB,CAAC;AAE/B,6EAA6E;AAC7E,eAAe;AACf,MAAM,SAAS,GAAG,uBAAuB,CAAC;AAE1C,8EAA8E;AAC9E,0DAA0D;AAC1D,MAAM,wBAAwB,GAAG,UAAU,CAAC;AAE5C,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,QAA4B,EAC5B,MAAc,EACd,KAAa,EACL,EAAE;IACV,MAAM,IAAI,GAAG,QAAQ,IAAI,MAAM,CAAC;IAEhC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,SAAS,CAAC,EAAE,QAAQ,KAAK,SAAS,CAAC,CACxE,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,CAAC,UAAU,CAAC,wBAAwB,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,KAAK,CAAC,gBAAgB,CAAC,KAAK,EAAE,IAAI,EAAE,wBAAwB,CAAC,CAAC,CAAC;IAC3E,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CACpC,KAAwC,EAC9B,EAAE;IACZ,MAAM,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IACvC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE;QAChC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAE7B,IAAI,KAAK,EAAE,CAAC;YACV,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;YACrD,OAAO;QACT,CAAC;QAED,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC"}
1
+ {"version":3,"file":"tool-name.util.js","sourceRoot":"","sources":["../../lib/tools/tool-name.util.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,EACL,iBAAiB,EACjB,eAAe,EACf,gBAAgB,GACjB,MAAM,uBAAuB,CAAC;AAE/B,6EAA6E;AAC7E,eAAe;AACf,MAAM,SAAS,GAAG,uBAAuB,CAAC;AAE1C,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,QAA4B,EAC5B,MAAc,EACd,KAAa,EACL,EAAE;IACV,MAAM,IAAI,GAAG,QAAQ,IAAI,MAAM,CAAC;IAEhC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,eAAe,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,SAAS,CAAC,EAAE,QAAQ,KAAK,SAAS,CAAC,CACxE,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,CAAC,UAAU,CAAC,wBAAwB,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,KAAK,CAAC,gBAAgB,CAAC,KAAK,EAAE,IAAI,EAAE,wBAAwB,CAAC,CAAC,CAAC;IAC3E,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CACpC,KAAwC,EAC9B,EAAE;IACZ,MAAM,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IACvC,MAAM,QAAQ,GAAa,EAAE,CAAC;IAE9B,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE;QAChC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAE7B,IAAI,KAAK,EAAE,CAAC;YACV,QAAQ,CAAC,IAAI,CAAC,iBAAiB,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;YACrD,OAAO;QACT,CAAC;QAED,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IACxB,CAAC,CAAC,CAAC;IAEH,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC"}
package/lib/agent.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
2
2
  import { DiscoveryService } from '@nestjs/core';
3
3
  import { createAgent, initChatModel } from 'langchain';
4
+ import type { z, ZodType } from 'zod';
4
5
  import {
5
6
  AGENT_NAME_TOKEN,
6
7
  MODULE_OPTIONS_TOKEN,
@@ -11,6 +12,7 @@ import {
11
12
  ModelOption,
12
13
  } from './interfaces/langchain-module-options.interface.js';
13
14
  import { CompletedRun } from './interfaces/completed-run.interface.js';
15
+ import { RunOptions } from './interfaces/run-options.interface.js';
14
16
  import {
15
17
  ToolDiscoveryService,
16
18
  toolErrors,
@@ -18,8 +20,9 @@ import {
18
20
  } from './tools/index.js';
19
21
  import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
20
22
  import { LOG_CONTEXT, messageOf, RunReporter } from './logging/index.js';
21
- import { summariseRun } from './run/index.js';
22
- import { AgentRunError } from './errors/index.js';
23
+ import { summariseRun, UsageAside } from './run/index.js';
24
+ import { outputConfig, RunOutput, structuredOutput } from './output/index.js';
25
+ import { AgentRunError, unwrapMiddlewareError } from './errors/index.js';
23
26
  import {
24
27
  agentNotBootstrapped,
25
28
  agentPrefix,
@@ -106,8 +109,16 @@ export class Agent implements OnModuleInit {
106
109
  model: await this.resolveModel(this.options.model),
107
110
  tools,
108
111
  systemPrompt: this.options.systemPrompt,
109
- // The first wraps the next: `toolErrors` sits right around the tool.
110
- middleware: [unknownTools(this.reporter), toolErrors(this.reporter)],
112
+ // The first wraps the next: `toolErrors` sits right around the tool,
113
+ // `structuredOutput` right around the model.
114
+ middleware: [
115
+ unknownTools(
116
+ this.reporter,
117
+ tools.map((tool) => tool.name),
118
+ ),
119
+ toolErrors(this.reporter),
120
+ structuredOutput(),
121
+ ],
111
122
  });
112
123
 
113
124
  this.logger.log(
@@ -179,21 +190,45 @@ export class Agent implements OnModuleInit {
179
190
  * as an `AgentRunError`, the original error in its `cause`.
180
191
  *
181
192
  * @param input The user's message.
182
- * @returns The model's answer, the tools it called and what the run cost.
193
+ * @param options With `output`, the schema the answer must match.
194
+ * @returns The model's answer, the object `output` parses when given, the
195
+ * tools it called and what the run cost.
183
196
  * @example
184
197
  * ```ts
185
198
  * const { output, tools } = await agent.run('What is 6 times 7?');
199
+ *
200
+ * const { output: ticket } = await agent.run(email, {
201
+ * output: z.object({ category: z.enum(['billing', 'bug', 'other']) }),
202
+ * });
186
203
  * ```
187
204
  */
188
- async run(input: string): Promise<CompletedRun> {
205
+ run<S extends ZodType>(
206
+ input: string,
207
+ options: RunOptions & { output: S },
208
+ ): Promise<CompletedRun<z.output<S>>>;
209
+ run(
210
+ input: string,
211
+ options?: RunOptions & { output?: undefined },
212
+ ): Promise<CompletedRun>;
213
+ // Options typed `RunOptions`, whose schema is not known statically. See #280.
214
+ run(input: string, options?: RunOptions): Promise<CompletedRun<unknown>>;
215
+ async run(
216
+ input: string,
217
+ options?: RunOptions,
218
+ ): Promise<CompletedRun<unknown>> {
189
219
  const startedAt = Date.now();
190
220
  const durationMs = () => Date.now() - startedAt;
191
221
 
192
222
  try {
193
- const { answer, messages } = await this.complete(input);
223
+ // Read in the `try`, and `null` from untyped JavaScript taken as no
224
+ // options, as LangChain takes a `null` config. See #291.
225
+ const schema = options?.output;
226
+ const output = schema && new RunOutput(schema, this.agentName);
227
+ const aside = new UsageAside();
228
+ const { answer, messages } = await this.complete(input, output, aside);
194
229
  const finished = {
195
230
  durationMs: durationMs(),
196
- ...summariseRun(messages),
231
+ ...summariseRun(messages, output?.toolName, aside.usage),
197
232
  };
198
233
 
199
234
  this.reporter.notify((observer) =>
@@ -202,13 +237,14 @@ export class Agent implements OnModuleInit {
202
237
 
203
238
  return { status: 'completed', output: answer, ...finished };
204
239
  } catch (thrown) {
240
+ const cause = unwrapMiddlewareError(thrown);
205
241
  const error =
206
- thrown instanceof AgentRunError
207
- ? thrown
242
+ cause instanceof AgentRunError
243
+ ? cause
208
244
  : new AgentRunError(
209
- agentRunFailed(this.prefix, thrown),
245
+ agentRunFailed(this.prefix, cause),
210
246
  this.agentName,
211
- { cause: thrown },
247
+ { cause },
212
248
  );
213
249
 
214
250
  this.reporter.notify((observer) =>
@@ -225,7 +261,9 @@ export class Agent implements OnModuleInit {
225
261
 
226
262
  private async complete(
227
263
  input: string,
228
- ): Promise<{ answer: string; messages: AgentState['messages'] }> {
264
+ output: RunOutput | undefined,
265
+ aside: UsageAside,
266
+ ): Promise<{ answer: unknown; messages: AgentState['messages'] }> {
229
267
  if (!this.#graph) {
230
268
  throw new AgentRunError(
231
269
  agentNotBootstrapped(this.prefix),
@@ -233,10 +271,14 @@ export class Agent implements OnModuleInit {
233
271
  );
234
272
  }
235
273
 
236
- const { messages }: AgentState = await this.#graph.invoke(
274
+ const state: AgentState = await this.#graph.invoke(
237
275
  { messages: [{ role: 'user', content: input }] },
238
- { callbacks: [this.modelErrors, ...(this.options.callbacks ?? [])] },
276
+ {
277
+ callbacks: [this.modelErrors, ...(this.options.callbacks ?? [])],
278
+ configurable: { ...outputConfig(output), ...aside.config },
279
+ },
239
280
  );
281
+ const { messages } = state;
240
282
 
241
283
  const last = messages[messages.length - 1];
242
284
 
@@ -247,6 +289,10 @@ export class Agent implements OnModuleInit {
247
289
  );
248
290
  }
249
291
 
292
+ if (output) {
293
+ return { answer: output.read(state.structuredResponse), messages };
294
+ }
295
+
250
296
  // `text`, not `content`: block answers are an array.
251
297
  if (!last.text) {
252
298
  throw new AgentRunError(modelReplyEmpty(this.prefix), this.agentName);
package/lib/constants.ts CHANGED
@@ -5,6 +5,14 @@ export const TOOL_PARAMS_METADATA = 'nestjs-langchain:tool-params';
5
5
  // Emitted by `emitDecoratorMetadata` on every decorated method.
6
6
  export const PARAM_TYPES_METADATA = 'design:paramtypes';
7
7
 
8
+ // The agent routes a call with this prefix to structured output, never to the
9
+ // tool. Restated: LangChain does not export it. See #213.
10
+ export const STRUCTURED_OUTPUT_PREFIX = 'extract-';
11
+
12
+ // Ours rather than LangChain's counter or the schema's title: the same tool on
13
+ // every run, under the prefix no `@Tool` can take. See #281.
14
+ export const OUTPUT_TOOL = `${STRUCTURED_OUTPUT_PREFIX}output`;
15
+
8
16
  // The keys `@Module()` writes, restated: `@nestjs/common/constants` is
9
17
  // internal. See #97.
10
18
  export const MODULE_METADATA_KEYS = [
@@ -1,2 +1,4 @@
1
1
  export { AgentRunError } from './agent-run.error.js';
2
+ export { StructuredOutputError } from './structured-output.error.js';
2
3
  export { ToolConfigurationError } from './tool-configuration.error.js';
4
+ export { unwrapMiddlewareError } from './unwrap-middleware-error.util.js';
@@ -96,14 +96,17 @@ export const schemaContradictsSignature = (
96
96
  `${where}: the schema for "${param}" describes ${declared.join(' | ')} ` +
97
97
  `but the signature declares ${expected}.`;
98
98
 
99
+ // The advice of both messages on a schema with no JSON Schema form.
100
+ const jsonSchemaAdvice =
101
+ 'Declare a schema JSON Schema can express and convert it with .transform(), ' +
102
+ 'such as z.iso.date() or z.iso.datetime() for a date.';
103
+
99
104
  export const noJsonSchemaForm = (
100
105
  where: string,
101
106
  param: string,
102
107
  reason: string,
103
108
  ): string =>
104
- `${where}: "${param}" has no JSON Schema form (${reason}). ` +
105
- 'Declare a schema JSON Schema can express and convert it with .transform(), ' +
106
- 'such as z.iso.datetime() for a date.';
109
+ `${where}: "${param}" has no JSON Schema form (${reason}). ${jsonSchemaAdvice}`;
107
110
 
108
111
  export const duplicateParamName = (
109
112
  where: string,
@@ -155,4 +158,41 @@ export const modelNeverReplied = (
155
158
  export const modelReplyEmpty = (prefix: string): string =>
156
159
  `The ${prefix}agent's model replied with no text content.`;
157
160
 
161
+ export const noOutputJsonSchemaForm = (
162
+ prefix: string,
163
+ reason: string,
164
+ ): string =>
165
+ `The ${prefix}agent's \`output\` schema has no JSON Schema form (${reason}). ` +
166
+ jsonSchemaAdvice;
167
+
168
+ export const outputNotAnObject = (prefix: string): string =>
169
+ `The ${prefix}agent's \`output\` schema is not an object with named fields: ` +
170
+ "the model fills it in as a tool's parameters. Wrap it, such as " +
171
+ '`z.object({ items: z.array(...) })`.';
172
+
173
+ export const structuredOutputMissing = (prefix: string): string =>
174
+ `The ${prefix}agent's model replied without the requested structured output.`;
175
+
176
+ export const structuredOutputInvalid = (
177
+ prefix: string,
178
+ reason: string,
179
+ ): string =>
180
+ `The ${prefix}agent's model replied with an output that does not match ` +
181
+ `the schema: ${reason}`;
182
+
183
+ export const structuredOutputMultiple = (
184
+ prefix: string,
185
+ count: number,
186
+ ): string =>
187
+ `The ${prefix}agent's model replied with ${count} outputs instead of one.`;
188
+
158
189
  export const unknownTool = (): string => 'not a tool of this agent';
190
+
191
+ // LangChain's answer to a tool the agent does not have, restated:
192
+ // `getInvalidToolError` is not exported. See #264.
193
+ export const invalidTool = (tool: string, tools: string[]): string =>
194
+ `Error: ${tool} is not a valid tool, try one of [${tools.join(', ')}].`;
195
+
196
+ export const toolNotRun = (tool: string): string =>
197
+ `Error: '${tool}' was not run: it came with the final answer. ` +
198
+ 'Call it on its own first if it is needed.';
@@ -0,0 +1,23 @@
1
+ import { AgentRunError } from './agent-run.error.js';
2
+
3
+ /**
4
+ * The model's answer to a run with an `output` schema never came, or does not
5
+ * match the schema. LangChain's or Zod's error, if any, is in `cause`.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * try {
10
+ * const { output } = await agent.run(ticket, { output: Classification });
11
+ * } catch (error) {
12
+ * if (error instanceof StructuredOutputError) {
13
+ * console.error(error.message, error.cause);
14
+ * }
15
+ * }
16
+ * ```
17
+ */
18
+ export class StructuredOutputError extends AgentRunError {
19
+ constructor(message: string, agent: string, options?: { cause?: unknown }) {
20
+ super(message, agent, options);
21
+ this.name = 'StructuredOutputError';
22
+ }
23
+ }
@@ -0,0 +1,19 @@
1
+ import { MiddlewareError } from 'langchain';
2
+
3
+ /**
4
+ * The error as thrown, without the `MiddlewareError` LangChain wraps it in at
5
+ * each `wrapModelCall` middleware it crosses, a provider error included.
6
+ *
7
+ * @internal
8
+ * @param error What the agent's graph threw.
9
+ * @returns The innermost error.
10
+ */
11
+ export const unwrapMiddlewareError = (error: unknown): unknown => {
12
+ let unwrapped = error;
13
+
14
+ while (MiddlewareError.isInstance(unwrapped) && unwrapped.cause) {
15
+ unwrapped = unwrapped.cause;
16
+ }
17
+
18
+ return unwrapped;
19
+ };
package/lib/index.ts CHANGED
@@ -3,7 +3,11 @@
3
3
  export { LangChainModule } from './langchain.module.js';
4
4
  export { getAgentToken } from './langchain.module-definition.js';
5
5
  export { Agent } from './agent.js';
6
- export { AgentRunError, ToolConfigurationError } from './errors/index.js';
6
+ export {
7
+ AgentRunError,
8
+ StructuredOutputError,
9
+ ToolConfigurationError,
10
+ } from './errors/index.js';
7
11
  export { InjectAgent, Tool, ToolParam } from './decorators/index.js';
8
12
  export type { ToolOptions, ToolParamOptions } from './decorators/index.js';
9
13
  export type {
@@ -16,6 +20,7 @@ export type {
16
20
  ToolStartEvent,
17
21
  } from './interfaces/agent-observer.interface.js';
18
22
  export type { CompletedRun } from './interfaces/completed-run.interface.js';
23
+ export type { RunOptions } from './interfaces/run-options.interface.js';
19
24
  export type {
20
25
  AgentCallback,
21
26
  LangChainModuleOptions,
@@ -7,7 +7,7 @@ import type { RunSummary } from '../run/index.js';
7
7
  export interface CompletedRun<TOutput = string> extends RunSummary {
8
8
  /** Always `'completed'`: a run that fails throws instead. */
9
9
  status: 'completed';
10
- /** The model's text answer. */
10
+ /** The model's text answer, or the object the run's `output` schema parsed. */
11
11
  output: TOutput;
12
12
  /** How long the run took. */
13
13
  durationMs: number;
@@ -0,0 +1,21 @@
1
+ import type { ZodType } from 'zod';
2
+
3
+ /**
4
+ * The options of one run. Passed inline, they type `output` from the schema;
5
+ * typed `RunOptions`, `output` is `unknown`.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const { output } = await agent.run(email, { output: Ticket }); // a Ticket
10
+ *
11
+ * const ask = (question: string, options?: RunOptions) =>
12
+ * agent.run(question, options); // `output` is unknown
13
+ * ```
14
+ */
15
+ export interface RunOptions {
16
+ /**
17
+ * The schema the answer must match. `output` is then the object it parses,
18
+ * typed from it, instead of the model's text.
19
+ */
20
+ output?: ZodType;
21
+ }
@@ -0,0 +1,5 @@
1
+ export { RunOutput } from './run-output.js';
2
+ export {
3
+ outputConfig,
4
+ structuredOutput,
5
+ } from './structured-output.middleware.js';
@@ -0,0 +1,154 @@
1
+ import { toJsonSchema } from '@langchain/core/utils/json_schema';
2
+ import {
3
+ MultipleStructuredOutputsError,
4
+ StructuredOutputParsingError,
5
+ toolStrategy,
6
+ TypedToolStrategy,
7
+ } from 'langchain';
8
+ import z, { ZodType } from 'zod';
9
+ import { OUTPUT_TOOL } from '../constants.js';
10
+ import { AgentRunError, StructuredOutputError } from '../errors/index.js';
11
+ import {
12
+ agentPrefix,
13
+ noOutputJsonSchemaForm,
14
+ outputNotAnObject,
15
+ structuredOutputInvalid,
16
+ structuredOutputMissing,
17
+ structuredOutputMultiple,
18
+ } from '../errors/messages.js';
19
+ import { messageOf } from '../logging/index.js';
20
+
21
+ // The default of LangChain's own retry middlewares. See #151.
22
+ const MAX_RETRIES = 2;
23
+
24
+ // LangChain's default for a Zod schema, which its JSON Schema path leaves
25
+ // empty. See #282.
26
+ const OUTPUT_DESCRIPTION =
27
+ "Tool for extracting structured output from the model's response.";
28
+
29
+ /**
30
+ * The structured output one run asked for: the strategy its model calls use,
31
+ * and the answer parsed by the schema.
32
+ *
33
+ * @internal
34
+ */
35
+ export class RunOutput {
36
+ /**
37
+ * Through a tool, whatever the model (see #274). One per run, which counts
38
+ * its own retries (see #151).
39
+ */
40
+ readonly strategy: TypedToolStrategy;
41
+ /** The tool the model answers through. */
42
+ readonly toolName = OUTPUT_TOOL;
43
+ #retries = 0;
44
+ private readonly prefix: string;
45
+
46
+ /**
47
+ * @param schema The run's `output` schema.
48
+ * @param agent The agent's name, for the errors.
49
+ * @throws AgentRunError when the schema has no JSON Schema form, or is not
50
+ * an object with named fields.
51
+ */
52
+ constructor(
53
+ private readonly schema: ZodType,
54
+ private readonly agent: string,
55
+ ) {
56
+ this.prefix = agentPrefix(agent);
57
+
58
+ // LangChain's own conversion, run before any model call.
59
+ let json: Record<string, unknown>;
60
+
61
+ try {
62
+ json = toJsonSchema(schema);
63
+ } catch (error) {
64
+ throw new AgentRunError(
65
+ noOutputJsonSchemaForm(this.prefix, messageOf(error)),
66
+ agent,
67
+ { cause: error },
68
+ );
69
+ }
70
+
71
+ // A tool's parameters. LangChain takes them as a JSON Schema with
72
+ // `properties` at every version, as a Zod schema other than an object at
73
+ // recent ones only. See #282.
74
+ if (json.type !== 'object' || !('properties' in json)) {
75
+ throw new AgentRunError(outputNotAnObject(this.prefix), agent);
76
+ }
77
+
78
+ // LangChain names the tool after the title.
79
+ this.strategy = toolStrategy(
80
+ {
81
+ ...json,
82
+ type: 'object',
83
+ title: OUTPUT_TOOL,
84
+ description: json.description ?? OUTPUT_DESCRIPTION,
85
+ },
86
+ { handleError: (error) => this.retry(error) },
87
+ );
88
+ }
89
+
90
+ // What LangChain sends back by default, until the retries run out.
91
+ private retry(error: Error): string {
92
+ this.#retries += 1;
93
+
94
+ if (this.#retries > MAX_RETRIES) {
95
+ throw error;
96
+ }
97
+
98
+ return error.message;
99
+ }
100
+
101
+ /**
102
+ * @param error What a model call threw.
103
+ * @returns A `StructuredOutputError` for LangChain's parsing failures, the
104
+ * error itself otherwise.
105
+ */
106
+ failure(error: unknown): unknown {
107
+ // Never LangChain's messages, which name the internal tool.
108
+ if (error instanceof StructuredOutputParsingError) {
109
+ return this.invalid(error.errors.join(' '), error);
110
+ }
111
+
112
+ if (error instanceof MultipleStructuredOutputsError) {
113
+ return new StructuredOutputError(
114
+ structuredOutputMultiple(this.prefix, error.toolNames.length),
115
+ this.agent,
116
+ { cause: error },
117
+ );
118
+ }
119
+
120
+ return error;
121
+ }
122
+
123
+ /**
124
+ * @param structuredResponse What the run left in `structuredResponse`.
125
+ * @returns The answer, as the schema parses it.
126
+ * @throws StructuredOutputError when the answer is missing or invalid.
127
+ */
128
+ read(structuredResponse: unknown): unknown {
129
+ if (structuredResponse === undefined) {
130
+ throw new StructuredOutputError(
131
+ structuredOutputMissing(this.prefix),
132
+ this.agent,
133
+ );
134
+ }
135
+
136
+ // LangChain checks the JSON Schema only: Zod also applies transforms and
137
+ // refinements. See #151.
138
+ const parsed = this.schema.safeParse(structuredResponse);
139
+
140
+ if (!parsed.success) {
141
+ throw this.invalid(z.prettifyError(parsed.error), parsed.error);
142
+ }
143
+
144
+ return parsed.data;
145
+ }
146
+
147
+ private invalid(reason: string, cause: unknown): StructuredOutputError {
148
+ return new StructuredOutputError(
149
+ structuredOutputInvalid(this.prefix, reason),
150
+ this.agent,
151
+ { cause },
152
+ );
153
+ }
154
+ }
@@ -0,0 +1,44 @@
1
+ import { createMiddleware } from 'langchain';
2
+ import { RunOutput } from './run-output.js';
3
+
4
+ // The run's output travels under our own key in `configurable`, which
5
+ // LangGraph keeps out of the trace metadata and checkpoints. See #151.
6
+ const OUTPUT_KEY = 'nestjs-langchain:output';
7
+
8
+ /**
9
+ * The `configurable` entry that hands a run's output to the middleware. A
10
+ * text run sets it to `null`, or it would inherit the output of the run it is
11
+ * nested in, which LangChain hands down. See #295.
12
+ *
13
+ * @internal
14
+ * @param output The run's output, if it asked for one.
15
+ * @returns The entry to put in `configurable`.
16
+ */
17
+ export const outputConfig = (output: RunOutput | undefined) => ({
18
+ [OUTPUT_KEY]: output ?? null,
19
+ });
20
+
21
+ /**
22
+ * Sets the `responseFormat` of each model call of a run that asked for an
23
+ * `output`, so that one compiled agent serves text and structured runs.
24
+ * LangChain's parsing failures come out as a `StructuredOutputError`.
25
+ *
26
+ * @internal
27
+ */
28
+ export const structuredOutput = () =>
29
+ createMiddleware({
30
+ name: 'structuredOutput',
31
+ wrapModelCall: async (request, handler) => {
32
+ const output = request.runtime.configurable?.[OUTPUT_KEY];
33
+
34
+ if (!(output instanceof RunOutput)) {
35
+ return handler(request);
36
+ }
37
+
38
+ try {
39
+ return await handler({ ...request, responseFormat: output.strategy });
40
+ } catch (error) {
41
+ throw output.failure(error);
42
+ }
43
+ },
44
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The ids of the tool calls the messages answer: the tool node answers every
3
+ * call it runs. Structural rather than `BaseMessage`. See #52.
4
+ *
5
+ * @internal
6
+ * @param messages A run's messages.
7
+ * @returns The ids their `tool_call_id`s answer.
8
+ */
9
+ export const answeredCalls = (messages: readonly object[]): Set<string> =>
10
+ new Set(
11
+ messages.flatMap((message) =>
12
+ 'tool_call_id' in message && typeof message.tool_call_id === 'string'
13
+ ? [message.tool_call_id]
14
+ : [],
15
+ ),
16
+ );
package/lib/run/index.ts CHANGED
@@ -1,2 +1,4 @@
1
+ export { answeredCalls } from './answered-calls.util.js';
1
2
  export { summariseRun } from './run-summary.util.js';
3
+ export { UsageAside } from './usage-aside.js';
2
4
  export type { RunSummary } from './run-summary.util.js';
@@ -1,14 +1,19 @@
1
1
  import { mergeUsageMetadata, UsageMetadata } from '@langchain/core/messages';
2
+ import { answeredCalls } from './answered-calls.util.js';
2
3
 
3
4
  // Structural rather than `BaseMessage`. See #52.
4
5
  interface MessageLike {
5
6
  usage_metadata?: UsageMetadata;
6
- tool_calls?: { name: string }[];
7
+ tool_calls?: { name: string; id?: string }[];
8
+ tool_call_id?: string;
7
9
  }
8
10
 
9
11
  /** What a run did and cost. */
10
12
  export interface RunSummary {
11
- /** Every tool call the model made, failed and unknown ones included. */
13
+ /**
14
+ * Every tool call that reached the agent's tools, failed and unknown ones
15
+ * included, but not the one returning a structured output.
16
+ */
12
17
  tools: string[];
13
18
  /**
14
19
  * Summed over the run's model calls; absent when the provider reports no
@@ -22,16 +27,33 @@ export interface RunSummary {
22
27
  *
23
28
  * @internal
24
29
  * @param messages The messages of the finished run.
25
- * @returns The tool calls the model made, and the tokens if reported.
30
+ * @param outputTool The tool the model returned a structured output through.
31
+ * @param aside The usage of the replies the run left out of its messages.
32
+ * @returns The tool calls that reached the tools, and the tokens if reported.
26
33
  */
27
- export const summariseRun = (messages: readonly MessageLike[]): RunSummary => {
28
- const tools = messages.flatMap((message) =>
29
- (message.tool_calls ?? []).map((call) => call.name),
30
- );
31
-
32
- const reported = messages
33
- .map((message) => message.usage_metadata)
34
- .filter((usage) => usage !== undefined);
34
+ export const summariseRun = (
35
+ messages: readonly MessageLike[],
36
+ outputTool?: string,
37
+ aside: readonly UsageMetadata[] = [],
38
+ ): RunSummary => {
39
+ // One that did not run has no answer (see #290). One with no id cannot be
40
+ // told, and counts (see #295).
41
+ const answered = answeredCalls(messages);
42
+
43
+ const tools = messages
44
+ .flatMap((message) => message.tool_calls ?? [])
45
+ .filter(
46
+ ({ name, id }) =>
47
+ name !== outputTool && (id === undefined || answered.has(id)),
48
+ )
49
+ .map((call) => call.name);
50
+
51
+ const reported = [
52
+ ...messages
53
+ .map((message) => message.usage_metadata)
54
+ .filter((usage) => usage !== undefined),
55
+ ...aside,
56
+ ];
35
57
 
36
58
  if (reported.length === 0) {
37
59
  return { tools };