@cartanova/qgrid-cli 2.5.2 → 2.5.4

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 (68) hide show
  1. package/README.ko.md +7 -2
  2. package/README.md +7 -2
  3. package/bundle/dist/application/monit/monit.frame.js +3 -3
  4. package/bundle/dist/application/qgrid/oauth.js +1 -1
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +41 -15
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +74 -28
  7. package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
  8. package/bundle/dist/application/qgrid/tool-emulation-schema.js +121 -0
  9. package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
  10. package/bundle/dist/application/request-log/request-log.model.js +3 -3
  11. package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
  12. package/bundle/dist/application/token/token.model.js +2 -2
  13. package/bundle/dist/server-error-handler.js +15 -0
  14. package/bundle/dist/sonamu.config.js +4 -7
  15. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +5 -4
  16. package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
  17. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
  18. package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
  19. package/bundle/dist/utils/providers/common/strictifier.js +329 -42
  20. package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
  21. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
  22. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
  23. package/bundle/src/application/monit/monit.frame.ts +1 -1
  24. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
  25. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +358 -1
  26. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +50 -28
  27. package/bundle/src/application/qgrid/qgrid.frame.test.ts +629 -8
  28. package/bundle/src/application/qgrid/qgrid.frame.ts +107 -27
  29. package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
  30. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
  31. package/bundle/src/application/qgrid/tool-emulation-schema.ts +176 -0
  32. package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
  33. package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
  34. package/bundle/src/server-error-handler.ts +22 -0
  35. package/bundle/src/sonamu.config.test.ts +37 -0
  36. package/bundle/src/sonamu.config.ts +2 -5
  37. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +56 -0
  38. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +3 -3
  39. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
  40. package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
  41. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +66 -0
  42. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +12 -3
  43. package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
  44. package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
  45. package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
  46. package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
  47. package/bundle/src/utils/providers/common/strictifier.ts +513 -52
  48. package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
  49. package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
  50. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
  51. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
  52. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
  53. package/bundle/web-dist/client/assets/{index-BQuKeBC5.js → index-DiY5V2e3.js} +3 -3
  54. package/bundle/web-dist/client/assets/{logs-BSsHMIKT.js → logs-UZ4Cvdmc.js} +1 -1
  55. package/bundle/web-dist/client/assets/{monit-D5ypGmGi.js → monit-C5cJo-HD.js} +1 -1
  56. package/bundle/web-dist/client/assets/{routes-BsCbz225.js → routes-DEKEmoQN.js} +1 -1
  57. package/bundle/web-dist/client/assets/{show-CXPhLZCl.js → show-DnX6b8KG.js} +1 -1
  58. package/bundle/web-dist/client/assets/{tokens-gEGH52O8.js → tokens-Ct0FGD4b.js} +1 -1
  59. package/bundle/web-dist/client/index.html +1 -1
  60. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  61. package/package.json +1 -1
  62. package/skills/qgrid/references/ai-sdk-provider-contract.md +42 -2
  63. package/skills/qgrid/references/anthropic-claude-code-runtime.md +31 -1
  64. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  65. package/skills/qgrid/references/decision-rationale.md +11 -1
  66. package/skills/qgrid/references/repo-map.md +2 -1
  67. package/skills/qgrid/references/tool-calling-and-multiturn.md +33 -6
  68. package/skills/qgrid/references/verification-and-debugging.md +16 -1
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
3
  import { getLogger } from "@logtape/logtape";
4
- import { type FastifyReply } from "fastify";
4
+ import { type FastifyReply, type FastifyRequest } from "fastify";
5
5
  import { api, BadRequestException, BaseFrameClass, Sonamu, stream } from "sonamu";
6
6
  import { getCacheManagerRef } from "sonamu/cache";
7
7
 
@@ -11,6 +11,7 @@ import {
11
11
  getExpiresAt,
12
12
  getRefreshToken,
13
13
  } from "../../utils/providers/common/credentials";
14
+ import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
14
15
  import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
15
16
  import { type TokenSubsetA } from "../sonamu.generated";
16
17
  import { TokenModel, type TokenUpdateFields } from "../token/token.model";
@@ -29,7 +30,7 @@ import {
29
30
  finishRunAborted,
30
31
  finishRunWithError,
31
32
  } from "./qgrid-run-lifecycle";
32
- import { QgridDispatcher } from "./qgrid.dispatcher";
33
+ import { buildAndValidateStrictOutputSchema, QgridDispatcher } from "./qgrid.dispatcher";
33
34
  import {
34
35
  type QueryInput,
35
36
  type AppendStepInput,
@@ -43,6 +44,7 @@ import {
43
44
  type TokenStats,
44
45
  type UsageResponse,
45
46
  } from "./qgrid.types";
47
+ import { ToolSchemaCompositionError } from "./tool-emulation-schema";
46
48
 
47
49
  const pendingStreams = new Map<string, QueryInput>();
48
50
 
@@ -100,6 +102,66 @@ function rejectLegacyLogMode(args: QueryInput): void {
100
102
  );
101
103
  }
102
104
 
105
+ function rejectInvalidCallerSchemas(args: QueryInput): void {
106
+ if (args.jsonSchema === undefined && !args.tools?.length) return;
107
+
108
+ try {
109
+ buildAndValidateStrictOutputSchema(args);
110
+ } catch (error) {
111
+ if (
112
+ error instanceof CallerSchemaValidationError ||
113
+ error instanceof ToolSchemaCompositionError
114
+ ) {
115
+ throw new BadRequestException(error.message as LocalizedString);
116
+ }
117
+ // 잘못된 keyword 형태(anyOf 를 객체로 보내는 등)는 파이프라인에서 untyped 오류로 나오므로,
118
+ // 타입 좁히기 대신 Error 전체를 caller-fault 400 으로 매핑한다(계약 테스트로 고정됨).
119
+ if (error instanceof Error) {
120
+ throw new BadRequestException(
121
+ `qgrid: caller schema cannot be normalized: ${error.message}` as LocalizedString,
122
+ );
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ function createHttpDisconnectHandle(): {
129
+ signal: AbortSignal;
130
+ dispose: () => void;
131
+ } {
132
+ const abortController = new AbortController();
133
+ let requestRaw: FastifyRequest["raw"] | undefined;
134
+ let responseRaw: FastifyReply["raw"] | undefined;
135
+
136
+ try {
137
+ const ctx = Sonamu.getContext();
138
+ requestRaw = ctx.request?.raw;
139
+ if (ctx.transport === "http") responseRaw = ctx.reply.raw;
140
+ } catch {
141
+ // Direct frame calls (for example unit tests) do not have an HTTP context.
142
+ }
143
+
144
+ const abort = () => abortController.abort();
145
+ const abortOnIncompleteResponseClose = () => {
146
+ if (!responseRaw?.writableEnded) abort();
147
+ };
148
+
149
+ requestRaw?.once("aborted", abort);
150
+ responseRaw?.once("close", abortOnIncompleteResponseClose);
151
+
152
+ if (requestRaw?.aborted || (responseRaw?.destroyed && !responseRaw.writableEnded)) {
153
+ abort();
154
+ }
155
+
156
+ return {
157
+ signal: abortController.signal,
158
+ dispose: () => {
159
+ requestRaw?.removeListener("aborted", abort);
160
+ responseRaw?.removeListener("close", abortOnIncompleteResponseClose);
161
+ },
162
+ };
163
+ }
164
+
103
165
  const logger = getLogger(["qgrid"]);
104
166
  const oauthLogger = getLogger(["qgrid", "oauth"]);
105
167
 
@@ -111,33 +173,50 @@ class QgridFrameClass extends BaseFrameClass {
111
173
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
112
174
  async query(args: QueryInput): Promise<QueryOutput> {
113
175
  rejectLegacyLogMode(args);
114
- if (args.logger === false) {
115
- return QgridDispatcher.query(args, args.timeout);
116
- }
117
-
118
- const { requestLogId, stepIndex } = await beforeQuery(args);
119
- let result: QueryOutput;
176
+ rejectInvalidCallerSchemas(args);
177
+ const disconnect = createHttpDisconnectHandle();
120
178
  try {
121
- result = await QgridDispatcher.query(args, args.timeout);
122
- } catch (e) {
123
- await finishRunWithError(requestLogId, (e as Error).message, args);
124
- throw e;
125
- }
179
+ if (args.logger === false) {
180
+ return await QgridDispatcher.query(args, disconnect.signal);
181
+ }
126
182
 
127
- try {
128
- const lifecycle = await afterQuery(requestLogId, stepIndex, args, result);
129
- // tool-call requestLogId와 provider threadCoord는 서로 독립적으로 유지한다.
130
- const threadCoord = result.runContext?.threadCoord;
131
- const runContext =
132
- lifecycle.runContext || threadCoord
133
- ? { ...lifecycle.runContext, ...(threadCoord ? { threadCoord } : {}) }
134
- : undefined;
135
- return { ...result, runContext };
136
- } catch (e) {
137
- logger.error(`query afterQuery failed: ${(e as Error).message}`);
138
- await finishRunWithError(requestLogId, (e as Error).message, args);
139
- // provider 응답은 성공했으므로 로깅 장애가 생성 결과를 덮어쓰지 않는다.
140
- return result;
183
+ const { requestLogId, stepIndex } = await beforeQuery(args);
184
+ let result: QueryOutput;
185
+ try {
186
+ result = await QgridDispatcher.query(args, disconnect.signal);
187
+ } catch (e) {
188
+ if (disconnect.signal.aborted) {
189
+ await finishRunAborted(requestLogId, args);
190
+ } else {
191
+ await finishRunWithError(requestLogId, (e as Error).message, args);
192
+ }
193
+ throw e;
194
+ }
195
+
196
+ try {
197
+ const lifecycle = await afterQuery(requestLogId, stepIndex, args, result);
198
+ if (disconnect.signal.aborted) {
199
+ await finishRunAborted(requestLogId, args);
200
+ }
201
+ // tool-call requestLogId와 provider threadCoord는 서로 독립적으로 유지한다.
202
+ const threadCoord = result.runContext?.threadCoord;
203
+ const runContext =
204
+ lifecycle.runContext || threadCoord
205
+ ? { ...lifecycle.runContext, ...(threadCoord ? { threadCoord } : {}) }
206
+ : undefined;
207
+ return { ...result, runContext };
208
+ } catch (e) {
209
+ logger.error(`query afterQuery failed: ${(e as Error).message}`);
210
+ if (disconnect.signal.aborted) {
211
+ await finishRunAborted(requestLogId, args);
212
+ } else {
213
+ await finishRunWithError(requestLogId, (e as Error).message, args);
214
+ }
215
+ // provider 응답은 성공했으므로 로깅 장애가 생성 결과를 덮어쓰지 않는다.
216
+ return result;
217
+ }
218
+ } finally {
219
+ disconnect.dispose();
141
220
  }
142
221
  }
143
222
 
@@ -145,6 +224,7 @@ class QgridFrameClass extends BaseFrameClass {
145
224
  async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
146
225
  rejectLegacyLogMode(args);
147
226
  rejectImageGenerationStream(args);
227
+ rejectInvalidCallerSchemas(args);
148
228
  const streamId = crypto.randomUUID();
149
229
  pendingStreams.set(streamId, args);
150
230
  const expiry = setTimeout(() => pendingStreams.delete(streamId), 30_000);
@@ -69,6 +69,9 @@ export const QgridInputPart = z.discriminatedUnion("type", [
69
69
  ]);
70
70
  export type QgridInputPart = z.infer<typeof QgridInputPart>;
71
71
 
72
+ export const MAX_QUERY_TIMEOUT_MS = 30 * 60_000;
73
+ export const QueryTimeoutMs = z.number().int().positive().max(MAX_QUERY_TIMEOUT_MS);
74
+
72
75
  // ─── Run Lifecycle Context (SDK ↔ Server contract) ───
73
76
 
74
77
  // codex thread 좌표. thread 재사용으로 conversation_id(=prompt_cache_key)를 고정해
@@ -103,7 +106,7 @@ export const QueryInput = z
103
106
  prompt: z.string(),
104
107
  input: z.array(QgridInputPart).optional(),
105
108
  model: z.string().optional(),
106
- timeout: z.number().optional(),
109
+ timeout: QueryTimeoutMs.optional(),
107
110
  jsonSchema: z.string().optional(),
108
111
  tools: z.array(QgridTool).optional(),
109
112
  effort: z.string().optional(),
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { type QgridTool } from "./qgrid.types";
4
+ import { buildToolCallSchema } from "./tool-emulation-schema";
5
+
6
+ const userPath = (...path: Array<string | number>) => [
7
+ "$defs",
8
+ "__qgrid_user_output",
9
+ ...path,
10
+ ];
11
+
12
+ describe("buildToolCallSchema", () => {
13
+ const tools: QgridTool[] = [
14
+ {
15
+ name: "getWeather",
16
+ description: "Get weather for a city",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: { city: { type: "string" } },
20
+ required: ["city"],
21
+ additionalProperties: false,
22
+ },
23
+ },
24
+ ];
25
+
26
+ it("documents that emulated tools must be requested through StructuredOutput", () => {
27
+ const schema = buildToolCallSchema(tools);
28
+
29
+ expect(schema).toHaveProperty(
30
+ "description",
31
+ expect.stringContaining("Do not invoke listed client tools as native Claude Code tools"),
32
+ );
33
+ expect(schema).toHaveProperty(
34
+ ["properties", "action", "description"],
35
+ expect.stringContaining("tool_call"),
36
+ );
37
+ expect(schema).toHaveProperty(
38
+ ["properties", "toolCalls", "description"],
39
+ expect.stringContaining("Do not call these tools as native"),
40
+ );
41
+ expect(schema).toHaveProperty(
42
+ ["properties", "toolCalls", "anyOf", 0, "items", "properties", "toolName", "description"],
43
+ expect.stringContaining("getWeather"),
44
+ );
45
+ });
46
+
47
+ it("preserves the legacy string answer shape when no user schema is supplied", () => {
48
+ const schema = buildToolCallSchema(tools);
49
+
50
+ expect(schema).not.toHaveProperty("$defs");
51
+ expect(schema).toHaveProperty("properties.answer.anyOf", [
52
+ { type: "string" },
53
+ { type: "null" },
54
+ ]);
55
+ });
56
+
57
+ it("namespaces a user schema and references it from answer", () => {
58
+ const userSchema = {
59
+ type: "object",
60
+ properties: { translated: { type: "string" } },
61
+ };
62
+ const schema = buildToolCallSchema(tools, userSchema);
63
+
64
+ expect(schema).toHaveProperty("properties.answer.anyOf", [
65
+ { $ref: "#/$defs/__qgrid_user_output" },
66
+ { type: "null" },
67
+ ]);
68
+ expect(schema).toHaveProperty(userPath(), userSchema);
69
+ });
70
+
71
+ it("rebases root and nested local refs into the user-schema namespace", () => {
72
+ const schema = buildToolCallSchema(tools, {
73
+ type: "object",
74
+ properties: {
75
+ self: { $ref: "#" },
76
+ item: { $ref: "#/$defs/Item" },
77
+ },
78
+ $defs: {
79
+ Item: {
80
+ type: "object",
81
+ properties: { parent: { $ref: "#" } },
82
+ },
83
+ },
84
+ });
85
+
86
+ expect(schema).toHaveProperty(
87
+ userPath("properties", "self", "$ref"),
88
+ "#/$defs/__qgrid_user_output",
89
+ );
90
+ expect(schema).toHaveProperty(
91
+ userPath("properties", "item", "$ref"),
92
+ "#/$defs/__qgrid_user_output/$defs/Item",
93
+ );
94
+ expect(schema).toHaveProperty(
95
+ userPath("$defs", "Item", "properties", "parent", "$ref"),
96
+ "#/$defs/__qgrid_user_output",
97
+ );
98
+ });
99
+
100
+ it("only traverses schema positions and preserves property names and literal data", () => {
101
+ const literal = {
102
+ $id: "literal-id",
103
+ $ref: "https://example.com/literal.json",
104
+ $dynamicRef: "#literal",
105
+ };
106
+ const schema = buildToolCallSchema(tools, {
107
+ type: "object",
108
+ properties: {
109
+ $id: { type: "string" },
110
+ $ref: { type: "string" },
111
+ payload: {
112
+ type: "object",
113
+ const: literal,
114
+ enum: [literal],
115
+ default: literal,
116
+ examples: [literal],
117
+ },
118
+ },
119
+ patternProperties: {
120
+ "^\\$dynamicRef$": { type: "string" },
121
+ },
122
+ dependentSchemas: {
123
+ $dynamicRef: {
124
+ type: "object",
125
+ properties: { value: { $ref: "#" } },
126
+ },
127
+ },
128
+ });
129
+
130
+ expect(schema).toHaveProperty(userPath("properties", "$id"), { type: "string" });
131
+ expect(schema).toHaveProperty(userPath("properties", "$ref"), { type: "string" });
132
+ expect(schema).toHaveProperty(userPath("patternProperties", "^\\$dynamicRef$"), {
133
+ type: "string",
134
+ });
135
+ expect(schema).toHaveProperty(userPath("properties", "payload", "const"), literal);
136
+ expect(schema).toHaveProperty(userPath("properties", "payload", "enum"), [literal]);
137
+ expect(schema).toHaveProperty(userPath("properties", "payload", "default"), literal);
138
+ expect(schema).toHaveProperty(userPath("properties", "payload", "examples"), [literal]);
139
+ expect(schema).toHaveProperty(
140
+ userPath("dependentSchemas", "$dynamicRef", "properties", "value", "$ref"),
141
+ "#/$defs/__qgrid_user_output",
142
+ );
143
+ });
144
+
145
+ it("rebases draft-07 additionalItems and schema dependencies while preserving property dependencies", () => {
146
+ const schema = buildToolCallSchema(tools, {
147
+ type: "array",
148
+ items: [{ type: "string" }],
149
+ additionalItems: { $ref: "#/$defs/Extra" },
150
+ dependencies: {
151
+ triggerSchema: {
152
+ type: "object",
153
+ properties: { extra: { $ref: "#/$defs/Extra" } },
154
+ },
155
+ triggerProperties: ["first", "$ref", "$id"],
156
+ },
157
+ $defs: {
158
+ Extra: { type: "string" },
159
+ },
160
+ });
161
+
162
+ expect(schema).toHaveProperty(
163
+ userPath("additionalItems", "$ref"),
164
+ "#/$defs/__qgrid_user_output/$defs/Extra",
165
+ );
166
+ expect(schema).toHaveProperty(
167
+ userPath("dependencies", "triggerSchema", "properties", "extra", "$ref"),
168
+ "#/$defs/__qgrid_user_output/$defs/Extra",
169
+ );
170
+ expect(schema).toHaveProperty(
171
+ userPath("dependencies", "triggerProperties"),
172
+ ["first", "$ref", "$id"],
173
+ );
174
+ });
175
+
176
+ it.each([
177
+ [{ $ref: "https://example.com/schema.json" }, "$.$ref"],
178
+ [{ $ref: "#named-anchor" }, "$.$ref"],
179
+ [{ $id: "urn:example:schema", type: "object" }, "$.$id"],
180
+ [{ $dynamicRef: "#node" }, "$.$dynamicRef"],
181
+ [{ $recursiveRef: "#" }, "$.$recursiveRef"],
182
+ [{ $recursiveAnchor: true }, "$.$recursiveAnchor"],
183
+ ])("rejects unsupported reference features with their path", (answerSchema, path) => {
184
+ expect(() => buildToolCallSchema(tools, answerSchema)).toThrowError(
185
+ expect.objectContaining({ path }),
186
+ );
187
+ });
188
+ });
@@ -0,0 +1,176 @@
1
+ import { type JsonValue } from "../../codex-protocol/serde_json/JsonValue";
2
+ import { type QgridTool } from "./qgrid.types";
3
+
4
+ const USER_OUTPUT_DEFINITION = "__qgrid_user_output";
5
+ const USER_OUTPUT_REF = `#/$defs/${USER_OUTPUT_DEFINITION}`;
6
+
7
+ export class ToolSchemaCompositionError extends Error {
8
+ constructor(
9
+ message: string,
10
+ public path: string,
11
+ ) {
12
+ super(`tool schema composition: ${message} (at ${path})`);
13
+ this.name = "ToolSchemaCompositionError";
14
+ }
15
+ }
16
+
17
+ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue): JsonValue {
18
+ const toolDescriptions = tools
19
+ .map((tool) => {
20
+ const schema = JSON.stringify(tool.inputSchema);
21
+ return `- ${tool.name}: ${tool.description ?? ""}\n inputSchema: ${schema}`;
22
+ })
23
+ .join("\n");
24
+
25
+ const normalizedAnswerSchema =
26
+ answerSchema === undefined ? undefined : rebaseUserOutputSchema(answerSchema);
27
+
28
+ return {
29
+ type: "object",
30
+ description:
31
+ "Use this schema only through StructuredOutput. Do not invoke listed client tools as native Claude Code tools. To request client-side tool execution, set action to tool_call and include toolCalls. Use action answer only when no further client tool result is needed.",
32
+ properties: {
33
+ action: {
34
+ type: "string",
35
+ enum: ["answer", "tool_call"],
36
+ description:
37
+ "Use tool_call to request client-side tool execution through this structured output. Use answer only for a final answer.",
38
+ },
39
+ answer: {
40
+ anyOf: [
41
+ normalizedAnswerSchema === undefined ? { type: "string" } : { $ref: USER_OUTPUT_REF },
42
+ { type: "null" },
43
+ ],
44
+ },
45
+ toolCalls: {
46
+ description:
47
+ "Client-side tool calls requested through structured output. Do not call these tools as native Claude Code tools.",
48
+ anyOf: [
49
+ {
50
+ type: "array",
51
+ items: {
52
+ type: "object",
53
+ properties: {
54
+ toolName: {
55
+ type: "string",
56
+ enum: tools.map((tool) => tool.name),
57
+ description: `Client-side tool name to request through structured output. Do not call this as a native Claude Code tool.\n${toolDescriptions}`,
58
+ },
59
+ args: {
60
+ type: "string",
61
+ description: "Tool arguments as a JSON string for the client-side tool.",
62
+ },
63
+ },
64
+ required: ["toolName", "args"],
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ { type: "null" },
69
+ ],
70
+ },
71
+ },
72
+ required: ["action", "answer", "toolCalls"],
73
+ additionalProperties: false,
74
+ ...(normalizedAnswerSchema === undefined
75
+ ? {}
76
+ : {
77
+ $defs: {
78
+ [USER_OUTPUT_DEFINITION]: normalizedAnswerSchema,
79
+ },
80
+ }),
81
+ };
82
+ }
83
+
84
+ function rebaseUserOutputSchema(schema: JsonValue): JsonValue {
85
+ return rewriteSchemaNode(schema, "$");
86
+ }
87
+
88
+ const SCHEMA_MAP_KEYWORDS = new Set([
89
+ "$defs",
90
+ "definitions",
91
+ "dependentSchemas",
92
+ "patternProperties",
93
+ "properties",
94
+ ]);
95
+ const SCHEMA_ARRAY_KEYWORDS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
96
+ const SCHEMA_KEYWORDS = new Set([
97
+ "additionalItems",
98
+ "additionalProperties",
99
+ "contains",
100
+ "contentSchema",
101
+ "else",
102
+ "if",
103
+ "items",
104
+ "not",
105
+ "propertyNames",
106
+ "then",
107
+ "unevaluatedItems",
108
+ "unevaluatedProperties",
109
+ ]);
110
+
111
+ function rewriteSchemaNode(value: JsonValue, path: string): JsonValue {
112
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
113
+
114
+ const source = value as Record<string, JsonValue>;
115
+ const result: Record<string, JsonValue> = { ...source };
116
+
117
+ for (const unsupportedKeyword of ["$id", "$dynamicRef", "$recursiveRef", "$recursiveAnchor"]) {
118
+ if (unsupportedKeyword in source) {
119
+ throw new ToolSchemaCompositionError(
120
+ `${unsupportedKeyword} is not supported`,
121
+ `${path}.${unsupportedKeyword}`,
122
+ );
123
+ }
124
+ }
125
+
126
+ if ("$ref" in source) {
127
+ const ref = source.$ref;
128
+ if (typeof ref !== "string" || (ref !== "#" && !ref.startsWith("#/"))) {
129
+ throw new ToolSchemaCompositionError(
130
+ "only root-relative JSON Pointer $ref values are supported",
131
+ `${path}.$ref`,
132
+ );
133
+ }
134
+ result.$ref = `${USER_OUTPUT_REF}${ref.slice(1)}`;
135
+ }
136
+
137
+ for (const [keyword, child] of Object.entries(source)) {
138
+ const keywordPath = `${path}.${keyword}`;
139
+
140
+ if (SCHEMA_MAP_KEYWORDS.has(keyword) && isJsonObject(child)) {
141
+ result[keyword] = Object.fromEntries(
142
+ Object.entries(child).map(([name, schema]) => [
143
+ name,
144
+ rewriteSchemaNode(schema, `${keywordPath}.${name}`),
145
+ ]),
146
+ ) as JsonValue;
147
+ } else if (keyword === "dependencies" && isJsonObject(child)) {
148
+ result[keyword] = Object.fromEntries(
149
+ Object.entries(child).map(([name, dependency]) => [
150
+ name,
151
+ Array.isArray(dependency)
152
+ ? dependency
153
+ : rewriteSchemaNode(dependency, `${keywordPath}.${name}`),
154
+ ]),
155
+ ) as JsonValue;
156
+ } else if (SCHEMA_ARRAY_KEYWORDS.has(keyword) && Array.isArray(child)) {
157
+ result[keyword] = child.map((schema, index) =>
158
+ rewriteSchemaNode(schema, `${keywordPath}[${index}]`),
159
+ ) as JsonValue;
160
+ } else if (SCHEMA_KEYWORDS.has(keyword)) {
161
+ if (keyword === "items" && Array.isArray(child)) {
162
+ result[keyword] = child.map((schema, index) =>
163
+ rewriteSchemaNode(schema, `${keywordPath}[${index}]`),
164
+ ) as JsonValue;
165
+ } else {
166
+ result[keyword] = rewriteSchemaNode(child, keywordPath);
167
+ }
168
+ }
169
+ }
170
+
171
+ return result as JsonValue;
172
+ }
173
+
174
+ function isJsonObject(value: JsonValue): value is Record<string, JsonValue> {
175
+ return value !== null && typeof value === "object" && !Array.isArray(value);
176
+ }