@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.
- package/README.ko.md +7 -2
- package/README.md +7 -2
- package/bundle/dist/application/monit/monit.frame.js +3 -3
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +41 -15
- package/bundle/dist/application/qgrid/qgrid.frame.js +74 -28
- package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +121 -0
- package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
- package/bundle/dist/application/request-log/request-log.model.js +3 -3
- package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
- package/bundle/dist/application/token/token.model.js +2 -2
- package/bundle/dist/server-error-handler.js +15 -0
- package/bundle/dist/sonamu.config.js +4 -7
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +5 -4
- package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
- package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
- package/bundle/dist/utils/providers/common/strictifier.js +329 -42
- package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
- package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
- package/bundle/src/application/monit/monit.frame.ts +1 -1
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +358 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +50 -28
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +629 -8
- package/bundle/src/application/qgrid/qgrid.frame.ts +107 -27
- package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
- package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +176 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
- package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
- package/bundle/src/server-error-handler.ts +22 -0
- package/bundle/src/sonamu.config.test.ts +37 -0
- package/bundle/src/sonamu.config.ts +2 -5
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +56 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +3 -3
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +66 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +12 -3
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
- package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
- package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
- package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
- package/bundle/src/utils/providers/common/strictifier.ts +513 -52
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
- package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
- package/bundle/web-dist/client/assets/{index-BQuKeBC5.js → index-DiY5V2e3.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-BSsHMIKT.js → logs-UZ4Cvdmc.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-D5ypGmGi.js → monit-C5cJo-HD.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BsCbz225.js → routes-DEKEmoQN.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CXPhLZCl.js → show-DnX6b8KG.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-gEGH52O8.js → tokens-Ct0FGD4b.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +42 -2
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +31 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +11 -1
- package/skills/qgrid/references/repo-map.md +2 -1
- package/skills/qgrid/references/tool-calling-and-multiturn.md +33 -6
- 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
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const { requestLogId, stepIndex } = await beforeQuery(args);
|
|
119
|
-
let result: QueryOutput;
|
|
176
|
+
rejectInvalidCallerSchemas(args);
|
|
177
|
+
const disconnect = createHttpDisconnectHandle();
|
|
120
178
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
throw e;
|
|
125
|
-
}
|
|
179
|
+
if (args.logger === false) {
|
|
180
|
+
return await QgridDispatcher.query(args, disconnect.signal);
|
|
181
|
+
}
|
|
126
182
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
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
|
+
}
|