@agentica/core 0.45.0-dev.20260426 → 0.45.1

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.
@@ -1,6 +1,8 @@
1
+ import type { IJsonParseResult } from "@typia/interface";
1
2
  import type OpenAI from "openai";
2
3
  import type { ILlmFunction, IValidation } from "typia";
3
4
 
5
+ import { dedent, LlmJson } from "@typia/utils";
4
6
  import typia from "typia";
5
7
 
6
8
  import type { AgenticaContext } from "../context/AgenticaContext";
@@ -25,11 +27,19 @@ const FUNCTION: ILlmFunction = typia.llm.application<
25
27
  __IChatCancelFunctionsApplication
26
28
  >().functions[0]!;
27
29
 
28
- interface IFailure {
29
- id: string;
30
- name: string;
31
- validation: IValidation.IFailure;
32
- }
30
+ type IFailure
31
+ = | {
32
+ kind: "parse";
33
+ id: string;
34
+ name: string;
35
+ failure: IJsonParseResult.IFailure;
36
+ }
37
+ | {
38
+ kind: "validation";
39
+ id: string;
40
+ name: string;
41
+ validation: IValidation.IFailure;
42
+ };
33
43
 
34
44
  export async function cancel(
35
45
  ctx: AgenticaContext,
@@ -185,15 +195,56 @@ async function step(
185
195
  continue;
186
196
  }
187
197
 
188
- const input: object = FUNCTION.parse(tc.function.arguments) as object;
198
+ // LENIENT JSON PARSING
199
+ //
200
+ // A malformed JSON string is reported back to the LLM as a parse
201
+ // failure, mirroring `call.ts`. On success the parsed `.data` (never
202
+ // the `IJsonParseResult` wrapper itself) is forwarded to validation.
203
+ const parsed: IJsonParseResult<unknown> = FUNCTION.parse(
204
+ tc.function.arguments,
205
+ );
206
+ if (parsed.success === false) {
207
+ failures.push({
208
+ kind: "parse",
209
+ id: tc.id,
210
+ name: tc.function.name,
211
+ failure: parsed,
212
+ });
213
+ continue;
214
+ }
189
215
  const validation: IValidation<__IChatFunctionReference.IProps>
190
- = FUNCTION.validate(input) as IValidation<__IChatFunctionReference.IProps>;
216
+ = FUNCTION.validate(parsed.data) as IValidation<__IChatFunctionReference.IProps>;
191
217
  if (validation.success === false) {
192
218
  failures.push({
219
+ kind: "validation",
193
220
  id: tc.id,
194
221
  name: tc.function.name,
195
222
  validation,
196
223
  });
224
+ continue;
225
+ }
226
+
227
+ // FUNCTION EXISTENCE
228
+ //
229
+ // `typia` only proves that `name` is a `string`; it cannot know which
230
+ // functions are currently selected. A name that is not stacked would
231
+ // otherwise be silently dropped by `cancelFunctionFromContext`, so
232
+ // report it back to the LLM as an `IValidation.IFailure`.
233
+ const referenceErrors: IValidation.IError[] = validateFunctionExistence(
234
+ ctx,
235
+ validation.data,
236
+ );
237
+ if (referenceErrors.length > 0) {
238
+ failures.push({
239
+ kind: "validation",
240
+ id: tc.id,
241
+ name: tc.function.name,
242
+ validation: {
243
+ success: false,
244
+ data: validation.data,
245
+ errors: referenceErrors,
246
+ },
247
+ });
197
248
  }
198
249
  }
199
250
  }
@@ -217,15 +268,18 @@ async function step(
217
268
  continue;
218
269
  }
219
270
 
220
- const input: __IChatFunctionReference.IProps | null
221
- = typia.json.isParse<
222
- __IChatFunctionReference.IProps
223
- >(tc.function.arguments);
224
- if (input === null) {
271
+ // Reuse the lenient parser + validator so that arguments accepted
272
+ // by the VALIDATION retry above are processed consistently here.
273
+ const parsed: IJsonParseResult<unknown> = FUNCTION.parse(
274
+ tc.function.arguments,
275
+ );
276
+ const validation: IValidation<__IChatFunctionReference.IProps>
277
+ = FUNCTION.validate(parsed.data) as IValidation<__IChatFunctionReference.IProps>;
278
+ if (validation.success === false) {
225
279
  continue;
226
280
  }
227
281
 
228
- for (const reference of input.functions) {
282
+ for (const reference of validation.data.functions) {
229
283
  cancelFunctionFromContext(
230
284
  ctx,
231
285
  reference,
@@ -240,32 +294,92 @@ async function step(
240
294
  function emendMessages(failures: IFailure[]): OpenAI.ChatCompletionMessageParam[] {
241
295
  return failures
242
296
  .map(f => [
243
- {
244
- role: "assistant",
245
- tool_calls: [
246
- {
247
- type: "function",
248
- id: f.id,
249
- function: {
250
- name: f.name,
251
- arguments: JSON.stringify(f.validation.data),
252
- },
297
+ {
298
+ role: "assistant",
299
+ tool_calls: [
300
+ {
301
+ type: "function",
302
+ id: f.id,
303
+ function: {
304
+ name: f.name,
305
+ arguments: f.kind === "parse"
306
+ ? f.failure.input
307
+ : JSON.stringify(f.validation.data),
253
308
  },
254
- ],
255
- } satisfies OpenAI.ChatCompletionAssistantMessageParam,
256
- {
257
- role: "tool",
258
- content: JSON.stringify(f.validation.errors),
259
- tool_call_id: f.id,
260
- } satisfies OpenAI.ChatCompletionToolMessageParam,
261
- {
262
- role: "system",
263
- content: [
264
- "You A.I. assistant has composed wrong typed arguments.",
265
- "",
266
- "Correct it at the next function calling.",
267
- ].join("\n"),
268
- } satisfies OpenAI.ChatCompletionSystemMessageParam,
309
+ },
310
+ ],
311
+ } satisfies OpenAI.ChatCompletionAssistantMessageParam,
312
+ {
313
+ role: "tool",
314
+ tool_call_id: f.id,
315
+ content: f.kind === "parse"
316
+ ? dedent`
317
+ Invalid JSON format.
318
+
319
+ Here is the detailed parsing failure information,
320
+ including error messages and their locations within the input:
321
+
322
+ \`\`\`json
323
+ ${JSON.stringify(f.failure.errors)}
324
+ \`\`\`
325
+
326
+ And here is the partially parsed data that was successfully
327
+ extracted before the error occurred:
328
+
329
+ \`\`\`json
330
+ ${JSON.stringify(f.failure.data)}
331
+ \`\`\`
332
+ `
333
+ : [
334
+ "🚨 VALIDATION FAILURE: Your function arguments do not conform to the required schema.",
335
+ "",
336
+ "Each error below is computed absolute truth from rigorous type validation.",
337
+ "You must fix ALL errors to achieve 100% schema compliance.",
338
+ "",
339
+ LlmJson.stringify(f.validation),
340
+ ].join("\n"),
341
+ } satisfies OpenAI.ChatCompletionToolMessageParam,
342
+ {
343
+ role: "system",
344
+ content: f.kind === "parse"
345
+ ? AgenticaSystemPrompt.JSON_PARSE_ERROR.replace(
346
+ "${{FAILURE}}",
347
+ JSON.stringify(f.failure),
348
+ )
349
+ : AgenticaSystemPrompt.VALIDATE,
350
+ } satisfies OpenAI.ChatCompletionSystemMessageParam,
269
351
  ])
270
352
  .flat();
271
353
  }
354
+
355
+ /**
356
+ * Validate that every function to cancel is actually selected right now.
357
+ *
358
+ * `typia` validation only proves that `__IChatFunctionReference.name` is a
359
+ * `string`; it cannot know which functions are currently stacked. Without this
360
+ * check a name that is not selected would be silently dropped by
361
+ * `cancelFunctionFromContext`. The returned errors are fed back to the LLM
362
+ * through `emendMessages`, exactly like a type validation error - with the
363
+ * list of cancellable function names in `expected`.
364
+ */
365
+ function validateFunctionExistence(
366
+ ctx: AgenticaContext,
367
+ data: __IChatFunctionReference.IProps,
368
+ ): IValidation.IError[] {
369
+ const cancellable: string[] = ctx.stack.map(s => s.operation.name);
370
+ const expected: string = cancellable.length === 0
371
+ ? "never"
372
+ : cancellable.map(name => JSON.stringify(name)).join(" | ");
373
+ return data.functions.flatMap((reference, i): IValidation.IError[] =>
374
+ cancellable.includes(reference.name)
375
+ ? []
376
+ : [{
377
+ path: `$input.functions[${i}].name`,
378
+ expected,
379
+ value: reference.name,
380
+ description: cancellable.length === 0
381
+ ? `Function "${reference.name}" cannot be cancelled because no function is currently selected.`
382
+ : `Function "${reference.name}" is not in the current selection, so it cannot be cancelled.`,
383
+ }],
384
+ );
385
+ }
@@ -1,6 +1,8 @@
1
+ import type { IJsonParseResult } from "@typia/interface";
1
2
  import type OpenAI from "openai";
2
3
  import type { ILlmFunction, IValidation } from "typia";
3
4
 
5
+ import { dedent, LlmJson } from "@typia/utils";
4
6
  import typia from "typia";
5
7
 
6
8
  import type { AgenticaContext } from "../context/AgenticaContext";
@@ -28,11 +30,19 @@ const FUNCTION: ILlmFunction = typia.llm.application<
28
30
  __IChatSelectFunctionsApplication
29
31
  >().functions[0]!;
30
32
 
31
- interface IFailure {
32
- id: string;
33
- name: string;
34
- validation: IValidation.IFailure;
35
- }
33
+ type IFailure
34
+ = | {
35
+ kind: "parse";
36
+ id: string;
37
+ name: string;
38
+ failure: IJsonParseResult.IFailure;
39
+ }
40
+ | {
41
+ kind: "validation";
42
+ id: string;
43
+ name: string;
44
+ validation: IValidation.IFailure;
45
+ };
36
46
 
37
47
  export async function select(
38
48
  ctx: AgenticaContext,
@@ -241,15 +251,57 @@ async function step(
241
251
  if (tc.type !== "function" || tc.function.name !== "selectFunctions") {
242
252
  continue;
243
253
  }
244
- const input: object = FUNCTION.parse(tc.function.arguments) as object;
254
+ // LENIENT JSON PARSING
255
+ //
256
+ // A malformed JSON string is reported back to the LLM as a parse
257
+ // failure, mirroring `call.ts`. On success the parsed `.data` (never
258
+ // the `IJsonParseResult` wrapper itself) is forwarded to validation.
259
+ const parsed: IJsonParseResult<unknown> = FUNCTION.parse(
260
+ tc.function.arguments,
261
+ );
262
+ if (parsed.success === false) {
263
+ failures.push({
264
+ kind: "parse",
265
+ id: tc.id,
266
+ name: tc.function.name,
267
+ failure: parsed,
268
+ });
269
+ continue;
270
+ }
245
271
  const validation: IValidation<__IChatFunctionReference.IProps>
246
- = FUNCTION.validate(input) as IValidation<__IChatFunctionReference.IProps>;
272
+ = FUNCTION.validate(parsed.data) as IValidation<__IChatFunctionReference.IProps>;
247
273
  if (validation.success === false) {
248
274
  failures.push({
275
+ kind: "validation",
249
276
  id: tc.id,
250
277
  name: tc.function.name,
251
278
  validation,
252
279
  });
280
+ continue;
281
+ }
282
+
283
+ // FUNCTION EXISTENCE
284
+ //
285
+ // `typia` only proves that `name` is a `string`; it cannot know which
286
+ // functions exist at runtime. A hallucinated name would otherwise be
287
+ // silently dropped by `selectFunctionFromContext`, so report it back
288
+ // to the LLM as an `IValidation.IFailure`, just like a type error.
289
+ const referenceErrors: IValidation.IError[] = validateFunctionExistence(
290
+ ctx,
291
+ operations,
292
+ validation.data,
293
+ );
294
+ if (referenceErrors.length > 0) {
295
+ failures.push({
296
+ kind: "validation",
297
+ id: tc.id,
298
+ name: tc.function.name,
299
+ validation: {
300
+ success: false,
301
+ data: validation.data,
302
+ errors: referenceErrors,
303
+ },
304
+ });
253
305
  }
254
306
  }
255
307
  }
@@ -273,14 +325,17 @@ async function step(
273
325
  continue;
274
326
  }
275
327
 
276
- const input: __IChatFunctionReference.IProps | null
277
- = typia.json.isParse<__IChatFunctionReference.IProps>(
278
- tc.function.arguments,
279
- );
280
- if (input === null) {
328
+ // Reuse the lenient parser + validator so that arguments accepted
329
+ // by the VALIDATION retry above are processed consistently here.
330
+ const parsed: IJsonParseResult<unknown> = FUNCTION.parse(
331
+ tc.function.arguments,
332
+ );
333
+ const validation: IValidation<__IChatFunctionReference.IProps>
334
+ = FUNCTION.validate(parsed.data) as IValidation<__IChatFunctionReference.IProps>;
335
+ if (validation.success === false) {
281
336
  continue;
282
337
  }
283
- for (const reference of input.functions) {
338
+ for (const reference of validation.data.functions) {
284
339
  selectFunctionFromContext(
285
340
  ctx,
286
341
  reference,
@@ -303,24 +358,85 @@ function emendMessages(failures: IFailure[]): OpenAI.ChatCompletionMessageParam[
303
358
  id: f.id,
304
359
  function: {
305
360
  name: f.name,
306
- arguments: JSON.stringify(f.validation.data),
361
+ arguments: f.kind === "parse"
362
+ ? f.failure.input
363
+ : JSON.stringify(f.validation.data),
307
364
  },
308
365
  },
309
366
  ],
310
367
  } satisfies OpenAI.ChatCompletionAssistantMessageParam,
311
368
  {
312
369
  role: "tool",
313
- content: JSON.stringify(f.validation.errors),
314
370
  tool_call_id: f.id,
371
+ content: f.kind === "parse"
372
+ ? dedent`
373
+ Invalid JSON format.
374
+
375
+ Here is the detailed parsing failure information,
376
+ including error messages and their locations within the input:
377
+
378
+ \`\`\`json
379
+ ${JSON.stringify(f.failure.errors)}
380
+ \`\`\`
381
+
382
+ And here is the partially parsed data that was successfully
383
+ extracted before the error occurred:
384
+
385
+ \`\`\`json
386
+ ${JSON.stringify(f.failure.data)}
387
+ \`\`\`
388
+ `
389
+ : [
390
+ "🚨 VALIDATION FAILURE: Your function arguments do not conform to the required schema.",
391
+ "",
392
+ "Each error below is computed absolute truth from rigorous type validation.",
393
+ "You must fix ALL errors to achieve 100% schema compliance.",
394
+ "",
395
+ LlmJson.stringify(f.validation),
396
+ ].join("\n"),
315
397
  } satisfies OpenAI.ChatCompletionToolMessageParam,
316
398
  {
317
399
  role: "system",
318
- content: [
319
- "You A.I. assistant has composed wrong typed arguments.",
320
- "",
321
- "Correct it at the next function calling.",
322
- ].join("\n"),
400
+ content: f.kind === "parse"
401
+ ? AgenticaSystemPrompt.JSON_PARSE_ERROR.replace(
402
+ "${{FAILURE}}",
403
+ JSON.stringify(f.failure),
404
+ )
405
+ : AgenticaSystemPrompt.VALIDATE,
323
406
  } satisfies OpenAI.ChatCompletionSystemMessageParam,
324
407
  ])
325
408
  .flat();
326
409
  }
410
+
411
+ /**
412
+ * Validate that every selected function actually exists.
413
+ *
414
+ * `typia` validation only proves that `__IChatFunctionReference.name` is a
415
+ * `string`; it cannot know which functions exist at runtime. Without this
416
+ * check a hallucinated name would be silently dropped by
417
+ * `selectFunctionFromContext`. The returned errors are fed back to the LLM
418
+ * through `emendMessages`, exactly like a type validation error - with the
419
+ * list of valid function names in `expected`.
420
+ */
421
+ function validateFunctionExistence(
422
+ ctx: AgenticaContext,
423
+ candidates: AgenticaOperation[],
424
+ data: __IChatFunctionReference.IProps,
425
+ ): IValidation.IError[] {
426
+ const expected: string = candidates
427
+ .map(op => JSON.stringify(op.name))
428
+ .join(" | ");
429
+ return data.functions.flatMap((reference, i): IValidation.IError[] =>
430
+ ctx.operations.flat.has(reference.name)
431
+ ? []
432
+ : [{
433
+ path: `$input.functions[${i}].name`,
434
+ expected,
435
+ value: reference.name,
436
+ description: [
437
+ `Function "${reference.name}" does not exist.`,
438
+ "Select only from the functions provided by getApiFunctions().",
439
+ ].join(" "),
440
+ }],
441
+ );
442
+ }
@@ -95,13 +95,13 @@ function accumulate(origin: ChatCompletion, chunk: ChatCompletionChunk): ChatCom
95
95
  };
96
96
  }
97
97
 
98
- function merge(chunks: ChatCompletionChunk[]): ChatCompletion {
98
+ function mergeChunks(chunks: ChatCompletionChunk[]): ChatCompletion {
99
99
  const firstChunk = chunks[0];
100
100
  if (firstChunk === undefined) {
101
101
  throw new Error("No chunks received");
102
102
  }
103
103
 
104
- const result = chunks.reduce(accumulate, {
104
+ return chunks.reduce(accumulate, {
105
105
  id: firstChunk.id,
106
106
  choices: [],
107
107
  created: firstChunk.created,
@@ -111,17 +111,29 @@ function merge(chunks: ChatCompletionChunk[]): ChatCompletion {
111
111
  service_tier: firstChunk.service_tier,
112
112
  system_fingerprint: firstChunk.system_fingerprint,
113
113
  } as ChatCompletion);
114
+ }
114
115
 
115
- // post-process
116
- result.choices?.forEach((choice) => {
116
+ /**
117
+ * Replace any tool call's empty `arguments` with `"{}"`.
118
+ *
119
+ * MUST only be applied to a final, fully streamed completion. Running it while
120
+ * more chunks may still arrive would seed `"{}"` into a not-yet-complete tool
121
+ * call, and the remaining streamed argument chunks would then be appended onto
122
+ * it - corrupting the arguments into `{}{...}`.
123
+ */
124
+ function fixEmptyToolArguments(completion: ChatCompletion): ChatCompletion {
125
+ completion.choices?.forEach((choice) => {
117
126
  choice.message.tool_calls?.filter(tc => tc.type === "function").forEach((toolCall) => {
118
127
  if (toolCall.function.arguments === "") {
119
128
  toolCall.function.arguments = "{}";
120
129
  }
121
130
  });
122
131
  });
132
+ return completion;
133
+ }
123
134
 
124
- return result;
135
+ function merge(chunks: ChatCompletionChunk[]): ChatCompletion {
136
+ return fixEmptyToolArguments(mergeChunks(chunks));
125
137
  }
126
138
 
127
139
  function mergeChoice(acc: ChatCompletion.Choice, cur: ChatCompletionChunk.Choice): ChatCompletion.Choice {
@@ -221,6 +233,8 @@ export const ChatGptCompletionMessageUtil = {
221
233
  transformCompletionChunk,
222
234
  accumulate,
223
235
  merge,
236
+ mergeChunks,
237
+ fixEmptyToolArguments,
224
238
  mergeChoice,
225
239
  mergeToolCalls,
226
240
  };
@@ -56,7 +56,11 @@ async function reduceStreamingWithDispatch(stream: ReadableStream<ChatCompletion
56
56
  };
57
57
  if (acc.object === "chat.completion.chunk") {
58
58
  registerContext([acc, chunk].flatMap(v => v.choices ?? []));
59
- return ChatGptCompletionMessageUtil.merge([acc, chunk]);
59
+ // Use `mergeChunks`, NOT `merge`: `merge` runs the empty-arguments
60
+ // fixup, which mid-stream seeds a still-incomplete tool call with "{}"
61
+ // so the remaining streamed argument chunks append onto it (`{}{...}`).
62
+ // The fixup is applied once, to the final completion, below.
63
+ return ChatGptCompletionMessageUtil.mergeChunks([acc, chunk]);
60
64
  }
61
65
  registerContext(chunk.choices ?? []);
62
66
  return ChatGptCompletionMessageUtil.accumulate(acc, chunk);
@@ -85,7 +89,7 @@ async function reduceStreamingWithDispatch(stream: ReadableStream<ChatCompletion
85
89
  });
86
90
  return completion;
87
91
  }
88
- return nullableCompletion;
92
+ return ChatGptCompletionMessageUtil.fixEmptyToolArguments(nullableCompletion);
89
93
  }
90
94
 
91
95
  export { reduceStreamingWithDispatch };