@cartanova/qgrid-cli 2.5.1 → 2.5.3

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 (30) hide show
  1. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +6 -2
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +56 -24
  3. package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
  4. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +2 -2
  5. package/bundle/dist/utils/providers/anthropic/claude-session.js +9 -3
  6. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
  7. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +21 -1
  8. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +5 -3
  9. package/bundle/src/application/qgrid/qgrid.frame.test.ts +49 -0
  10. package/bundle/src/application/qgrid/qgrid.frame.ts +79 -26
  11. package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
  12. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +12 -0
  13. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +1 -1
  14. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +19 -0
  15. package/bundle/src/utils/providers/anthropic/claude-session.ts +24 -2
  16. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +66 -0
  17. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +12 -3
  18. package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
  19. package/bundle/web-dist/client/assets/{index-X4A9Xo_5.js → index-DXdGBfB8.js} +3 -3
  20. package/bundle/web-dist/client/assets/{logs-BK0afqFW.js → logs-D_j1yxC4.js} +1 -1
  21. package/bundle/web-dist/client/assets/{monit-D-lgl5dR.js → monit-CIToyoxW.js} +1 -1
  22. package/bundle/web-dist/client/assets/{routes-CyRdgbvG.js → routes-Dd21A-wi.js} +1 -1
  23. package/bundle/web-dist/client/assets/{show-DpxKqqVU.js → show-DfePWRbc.js} +1 -1
  24. package/bundle/web-dist/client/assets/{tokens-9eO1FvFK.js → tokens-B4MV2fdk.js} +1 -1
  25. package/bundle/web-dist/client/index.html +1 -1
  26. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  27. package/package.json +1 -1
  28. package/skills/qgrid/references/ai-sdk-provider-contract.md +8 -0
  29. package/skills/qgrid/references/anthropic-claude-code-runtime.md +36 -3
  30. package/skills/qgrid/references/decision-rationale.md +2 -0
@@ -1,3 +1,5 @@
1
+ import { EventEmitter } from "node:events";
2
+
1
3
  import { Sonamu } from "sonamu";
2
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
5
 
@@ -144,6 +146,10 @@ describe("QgridFrame.updateToken", () => {
144
146
  });
145
147
 
146
148
  describe("QgridFrame.query request logging", () => {
149
+ afterEach(() => {
150
+ vi.restoreAllMocks();
151
+ });
152
+
147
153
  beforeEach(() => {
148
154
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
149
155
  afterQueryMock.mockReset().mockResolvedValue({});
@@ -237,6 +243,42 @@ describe("QgridFrame.query request logging", () => {
237
243
  expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "provider failed", args);
238
244
  });
239
245
 
246
+ it("aborts provider execution and marks the run aborted when the HTTP response closes early", async () => {
247
+ const requestRaw = Object.assign(new EventEmitter(), { aborted: false });
248
+ const responseRaw = Object.assign(new EventEmitter(), {
249
+ destroyed: false,
250
+ writableEnded: false,
251
+ });
252
+ vi.spyOn(Sonamu, "getContext").mockReturnValue({
253
+ transport: "http",
254
+ request: { raw: requestRaw },
255
+ reply: { raw: responseRaw },
256
+ } as never);
257
+ let providerSignal: AbortSignal | undefined;
258
+ dispatcherQueryMock.mockImplementationOnce(
259
+ (_args: unknown, signal: AbortSignal) =>
260
+ new Promise((_resolve, reject) => {
261
+ providerSignal = signal;
262
+ signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
263
+ once: true,
264
+ });
265
+ }),
266
+ );
267
+ const args = { prompt: "hi", model: "anthropic/claude-opus-5" };
268
+
269
+ const queryPromise = QgridFrame.query(args);
270
+ await vi.waitFor(() => expect(providerSignal).toBeDefined());
271
+ responseRaw.destroyed = true;
272
+ responseRaw.emit("close");
273
+
274
+ await expect(queryPromise).rejects.toThrow("provider aborted");
275
+ expect(providerSignal?.aborted).toBe(true);
276
+ expect(finishRunAbortedMock).toHaveBeenCalledWith(41, args);
277
+ expect(finishRunWithErrorMock).not.toHaveBeenCalled();
278
+ expect(requestRaw.listenerCount("aborted")).toBe(0);
279
+ expect(responseRaw.listenerCount("close")).toBe(0);
280
+ });
281
+
240
282
  it("returns the provider result when afterQuery persistence fails", async () => {
241
283
  const output = queryOutput();
242
284
  dispatcherQueryMock.mockResolvedValueOnce(output);
@@ -266,6 +308,13 @@ describe("QgridFrame.query request logging", () => {
266
308
  expect(QueryInput.safeParse({ prompt: "hi", logMode: undefined }).success).toBe(false);
267
309
  expect(QueryInput.safeParse({ prompt: "hi", logger: false }).success).toBe(true);
268
310
  });
311
+
312
+ it("accepts bounded positive integer timeout values only", () => {
313
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 360_000 }).success).toBe(true);
314
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 0 }).success).toBe(false);
315
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 1.5 }).success).toBe(false);
316
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 1_800_001 }).success).toBe(false);
317
+ });
269
318
  });
270
319
 
271
320
  describe("QgridFrame.prepareStream", () => {
@@ -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
 
@@ -100,6 +100,43 @@ function rejectLegacyLogMode(args: QueryInput): void {
100
100
  );
101
101
  }
102
102
 
103
+ function createHttpDisconnectHandle(): {
104
+ signal: AbortSignal;
105
+ dispose: () => void;
106
+ } {
107
+ const abortController = new AbortController();
108
+ let requestRaw: FastifyRequest["raw"] | undefined;
109
+ let responseRaw: FastifyReply["raw"] | undefined;
110
+
111
+ try {
112
+ const ctx = Sonamu.getContext();
113
+ requestRaw = ctx.request?.raw;
114
+ if (ctx.transport === "http") responseRaw = ctx.reply.raw;
115
+ } catch {
116
+ // Direct frame calls (for example unit tests) do not have an HTTP context.
117
+ }
118
+
119
+ const abort = () => abortController.abort();
120
+ const abortOnIncompleteResponseClose = () => {
121
+ if (!responseRaw?.writableEnded) abort();
122
+ };
123
+
124
+ requestRaw?.once("aborted", abort);
125
+ responseRaw?.once("close", abortOnIncompleteResponseClose);
126
+
127
+ if (requestRaw?.aborted || (responseRaw?.destroyed && !responseRaw.writableEnded)) {
128
+ abort();
129
+ }
130
+
131
+ return {
132
+ signal: abortController.signal,
133
+ dispose: () => {
134
+ requestRaw?.removeListener("aborted", abort);
135
+ responseRaw?.removeListener("close", abortOnIncompleteResponseClose);
136
+ },
137
+ };
138
+ }
139
+
103
140
  const logger = getLogger(["qgrid"]);
104
141
  const oauthLogger = getLogger(["qgrid", "oauth"]);
105
142
 
@@ -111,33 +148,49 @@ class QgridFrameClass extends BaseFrameClass {
111
148
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
112
149
  async query(args: QueryInput): Promise<QueryOutput> {
113
150
  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;
151
+ const disconnect = createHttpDisconnectHandle();
120
152
  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
- }
153
+ if (args.logger === false) {
154
+ return await QgridDispatcher.query(args, disconnect.signal);
155
+ }
126
156
 
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;
157
+ const { requestLogId, stepIndex } = await beforeQuery(args);
158
+ let result: QueryOutput;
159
+ try {
160
+ result = await QgridDispatcher.query(args, disconnect.signal);
161
+ } catch (e) {
162
+ if (disconnect.signal.aborted) {
163
+ await finishRunAborted(requestLogId, args);
164
+ } else {
165
+ await finishRunWithError(requestLogId, (e as Error).message, args);
166
+ }
167
+ throw e;
168
+ }
169
+
170
+ try {
171
+ const lifecycle = await afterQuery(requestLogId, stepIndex, args, result);
172
+ if (disconnect.signal.aborted) {
173
+ await finishRunAborted(requestLogId, args);
174
+ }
175
+ // tool-call requestLogId와 provider threadCoord는 서로 독립적으로 유지한다.
176
+ const threadCoord = result.runContext?.threadCoord;
177
+ const runContext =
178
+ lifecycle.runContext || threadCoord
179
+ ? { ...lifecycle.runContext, ...(threadCoord ? { threadCoord } : {}) }
180
+ : undefined;
181
+ return { ...result, runContext };
182
+ } catch (e) {
183
+ logger.error(`query afterQuery failed: ${(e as Error).message}`);
184
+ if (disconnect.signal.aborted) {
185
+ await finishRunAborted(requestLogId, args);
186
+ } else {
187
+ await finishRunWithError(requestLogId, (e as Error).message, args);
188
+ }
189
+ // provider 응답은 성공했으므로 로깅 장애가 생성 결과를 덮어쓰지 않는다.
190
+ return result;
191
+ }
192
+ } finally {
193
+ disconnect.dispose();
141
194
  }
142
195
  }
143
196
 
@@ -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(),
@@ -175,6 +175,18 @@ describe("AnthropicDispatcher", () => {
175
175
  expect(call.jsonSchema).toBe(JSON.stringify({ type: "object", properties: {} }));
176
176
  });
177
177
 
178
+ it("요청 timeoutMs를 Claude session에 전달하고 미지정 시 240초 기본값을 쓴다", async () => {
179
+ const d = new AnthropicDispatcher();
180
+ d.onTokenAdded(1, "tok-A", creds(), null, 1);
181
+
182
+ await d.generate(baseReq({ timeoutMs: 360_000 }));
183
+ expect(firstRunRequest().timeoutMs).toBe(360_000);
184
+
185
+ runClaudeSessionMock.mockClear();
186
+ await d.generate(baseReq());
187
+ expect(firstRunRequest().timeoutMs).toBe(240_000);
188
+ });
189
+
178
190
  it("quota 소진 → 에러", async () => {
179
191
  const d = new AnthropicDispatcher();
180
192
  d.onTokenAdded(1, "tok-A", creds(), null, 1);
@@ -326,7 +326,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
326
326
  system: req.systemPrompt,
327
327
  jsonSchema,
328
328
  effort: req.effort,
329
- timeoutMs: DEFAULT_TIMEOUT_MS,
329
+ timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
330
330
  coldHistory: req.coldHistory,
331
331
  input: req.coldInput,
332
332
  abortSignal: req.abortSignal,
@@ -11,6 +11,7 @@ import {
11
11
  makeAnthropicWorkerId,
12
12
  oneMillionEnv,
13
13
  runClaudeSession,
14
+ shouldPinStructuredRetries,
14
15
  structuredOutputRetriesEnv,
15
16
  thinkingEnv,
16
17
  SYSTEM_PROMPT_ARGV_MAX_BYTES,
@@ -161,6 +162,24 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
161
162
  expect(structuredOutputRetriesEnv("5")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "5" });
162
163
  });
163
164
 
165
+ // SON-495 의 retry 고정 사유(스트림 retry = wall-clock 2배)는 스트리밍에만 성립한다.
166
+ // 논스트림 generate 까지 묶으면 1회 reject 가 곧 실패라 Claude 계열 structured 실패율이 치솟는다.
167
+ it("shouldPinStructuredRetries: structured + 스트리밍일 때만 retry 를 고정한다", () => {
168
+ expect(shouldPinStructuredRetries({ useStructured: true, includePartialMessages: true })).toBe(
169
+ true,
170
+ );
171
+ // 논스트림 structured: CC 기본 retry 허용
172
+ expect(shouldPinStructuredRetries({ useStructured: true, includePartialMessages: false })).toBe(
173
+ false,
174
+ );
175
+ expect(shouldPinStructuredRetries({ useStructured: true })).toBe(false);
176
+ // text 모드는 스트리밍 여부와 무관하게 영향 없음
177
+ expect(shouldPinStructuredRetries({ useStructured: false, includePartialMessages: true })).toBe(
178
+ false,
179
+ );
180
+ expect(shouldPinStructuredRetries({ useStructured: false })).toBe(false);
181
+ });
182
+
164
183
  it("cold-only: 항상 --session-id 를 사용하고 continuation flag 는 쓰지 않는다", () => {
165
184
  const args = buildClaudeArgs({ model: "m", sessionId: "uuid-1" });
166
185
  const continuationFlag = "--resume";
@@ -171,6 +171,22 @@ export function structuredOutputRetriesEnv(raw = process.env.MAX_STRUCTURED_OUTP
171
171
  return { MAX_STRUCTURED_OUTPUT_RETRIES: String(Math.max(parsed, 1)) };
172
172
  }
173
173
 
174
+ // SON-495 의 retry 고정은 "스트리밍 + structured" 조합 한정 정책이다. 스트림에서 retry 가 돌면
175
+ // wall-clock 이 2배가 되어 실용성이 없기 때문이다(원 결정 사유). 논스트림 generate 는 애초에 완료를
176
+ // 기다리므로 그 사유가 성립하지 않는다. 반면 Claude 계열은 OpenAI 대비 structured output 준수율이
177
+ // 낮아, 논스트림까지 retry=1 로 묶으면 1회 reject 가 곧바로
178
+ // `error_max_structured_output_retries` 로 직결된다(실측: deti_production opus-4-8 실패율 39.6%).
179
+ // 스트리밍 여부는 `--include-partial-messages` 를 붙이는 generateStream 경로에서만 true 가 되므로
180
+ // (anthropic-dispatcher 의 generate/generateStream 분기) 이를 실질 구분자로 쓴다.
181
+ // 비성공 subtype 을 항상 isError 로 올리는 SON-495 의 나머지 절반은 그대로 유지된다 — retry 후에도
182
+ // degenerate 출력이면 조용히 통과시키지 않고 정직하게 실패한다.
183
+ export function shouldPinStructuredRetries(opts: {
184
+ useStructured: boolean;
185
+ includePartialMessages?: boolean;
186
+ }): boolean {
187
+ return opts.useStructured && opts.includePartialMessages === true;
188
+ }
189
+
174
190
  // Linux 단일 argv 인자 한계(MAX_ARG_STRLEN = PAGE_SIZE×32 = 128KB, 커널 하드코딩 상수)
175
191
  // 시스템 프롬프트가 이를 넘으면 execve 가 E2BIG 로 거부된다. deti 등 대형 프롬프트는 실측
176
192
  // 평균 343KB·최대 485KB(opus-4-8)라 일상적으로 초과한다. 한계의 절반(64KB)을 임계값으로 잡아
@@ -311,8 +327,14 @@ export function runClaudeSession(
311
327
  CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
312
328
  CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
313
329
  CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
314
- // structured output 일 때만 retry 를 고정한다(SON-495). text 모드는 영향 없음.
315
- ...(useStructured ? structuredOutputRetriesEnv() : {}),
330
+ // structured + 스트리밍일 때만 retry 를 고정한다(SON-495). text 모드와 논스트림
331
+ // generate 는 CC 기본 retry 를 그대로 쓴다.
332
+ ...(shouldPinStructuredRetries({
333
+ useStructured,
334
+ includePartialMessages: req.includePartialMessages,
335
+ })
336
+ ? structuredOutputRetriesEnv()
337
+ : {}),
316
338
  },
317
339
  });
318
340
 
@@ -265,6 +265,72 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
265
265
  expect(result!.costUsd).toBe(0.0042);
266
266
  });
267
267
 
268
+ it("structured retry 성공: 첫 거절 입력이 아니라 최종 structured_output을 반환", () => {
269
+ const rejected = {
270
+ l2: "en",
271
+ line_id: "s1_l1",
272
+ speaker: "Minjae",
273
+ title: "첫 시도",
274
+ scenes: [],
275
+ };
276
+ const accepted = {
277
+ title: "두 번째 시도",
278
+ opening_line: "스키마를 만족한 최종 응답",
279
+ scenes: [],
280
+ };
281
+ const { result } = runLines(
282
+ [
283
+ JSON.stringify({
284
+ type: "assistant",
285
+ message: {
286
+ content: [{ type: "tool_use", name: "StructuredOutput", input: rejected }],
287
+ },
288
+ }),
289
+ JSON.stringify({
290
+ type: "user",
291
+ message: {
292
+ content: [
293
+ {
294
+ type: "tool_result",
295
+ is_error: true,
296
+ content: "Output does not match required schema",
297
+ },
298
+ ],
299
+ },
300
+ }),
301
+ JSON.stringify({
302
+ type: "assistant",
303
+ message: {
304
+ content: [{ type: "tool_use", name: "StructuredOutput", input: accepted }],
305
+ },
306
+ }),
307
+ JSON.stringify({
308
+ type: "user",
309
+ message: {
310
+ content: [
311
+ {
312
+ type: "tool_result",
313
+ content: "Structured output provided successfully",
314
+ },
315
+ ],
316
+ },
317
+ }),
318
+ JSON.stringify({
319
+ type: "result",
320
+ subtype: "success",
321
+ is_error: false,
322
+ structured_output: accepted,
323
+ usage: { input_tokens: 10, output_tokens: 20 },
324
+ }),
325
+ ],
326
+ { structuredOutput: true },
327
+ );
328
+
329
+ expect(JSON.parse(result!.text)).toEqual(accepted);
330
+ expect(result!.isError).toBe(false);
331
+ expect(result!.subtype).toBe("success");
332
+ });
333
+
268
334
  // SON-495: error_max_structured_output_retries 는 모델이 schema 를 못 맞춘 비정상 종료다.
269
335
  // CC structured output 은 constrained decoding 이 아니라 사후 AJV 검증이라, 모델이 가끔
270
336
  // placeholder($PARAMETER_NAME)·거부("just kidding")·필드 누락 같은 발작/불완전 출력을 낸다.
@@ -406,9 +406,20 @@ export function handleStreamJsonLine(
406
406
  }
407
407
 
408
408
  if (j.type === "result") {
409
+ const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
410
+ const terminalReason = typeof j.terminal_reason === "string" ? j.terminal_reason : undefined;
411
+ const resultSucceeded =
412
+ j.is_error !== true &&
413
+ terminalReason !== "model_error" &&
414
+ (subtype === undefined || subtype === "success");
409
415
  let text: string;
410
416
  const preservedStructuredOutput = structuredOutput ? state?.structuredOutputText : undefined;
411
- if (preservedStructuredOutput !== undefined) {
417
+ // Claude Code는 StructuredOutput 스키마 검증 실패 후 내부 재시도를 할 수 있다. assistant
418
+ // 이벤트의 첫 tool input은 거절된 시도일 수 있으므로, 성공 result가 제공한 최종 검증값을
419
+ // 우선한다. 첫 입력 보존값은 max_turns/retry 초과처럼 성공 result가 없는 진단 경로용이다.
420
+ if (structuredOutput && resultSucceeded && j.structured_output !== undefined) {
421
+ text = JSON.stringify(j.structured_output);
422
+ } else if (preservedStructuredOutput !== undefined) {
412
423
  text = preservedStructuredOutput;
413
424
  } else if (j.structured_output !== undefined) {
414
425
  text = JSON.stringify(j.structured_output);
@@ -429,8 +440,6 @@ export function handleStreamJsonLine(
429
440
  }
430
441
  const usage = toUsageBreakdown(rawUsage as ClaudeUsage);
431
442
  const quotaExhausted = text.startsWith("You've hit");
432
- const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
433
- const terminalReason = typeof j.terminal_reason === "string" ? j.terminal_reason : undefined;
434
443
 
435
444
  // SON-495: 비정상 종료(error_max_structured_output_retries 등)는 정직하게 에러로 처리한다.
436
445
  // CC structured output 은 constrained decoding 이 아니라 "tool input 생성 후 사후 AJV 검증"이라
@@ -55,6 +55,8 @@ export interface GenerateRequest {
55
55
  verbosity?: string;
56
56
  reasoningSummary?: string;
57
57
  serviceTier?: string;
58
+ // provider 실행 제한(ms). 현재 Anthropic Claude session 타이머가 사용한다.
59
+ timeoutMs?: number;
58
60
  abortSignal?: AbortSignal;
59
61
  // 첫 turn / 재사용 폴백 시 보낼 input — 전체 prompt. 항상 설정.
60
62
  coldInput: Array<UserInput>;