tinker-agent 1.2.0 → 1.3.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.
- package/CHANGELOG.md +20 -1
- package/package.json +1 -1
- package/src/agent/loop.ts +115 -11
- package/src/events/observation-text-log.ts +2 -0
- package/src/events/stdout-event-printer.ts +7 -1
- package/src/events/types.ts +27 -3
- package/src/model/model-client.ts +29 -0
- package/src/model/openai-chat-mapping.ts +54 -15
- package/src/model/openai-chat-model-client.ts +46 -27
- package/src/model/openai-chat-stream.ts +6 -2
- package/src/skills/skill-loader.ts +0 -20
- package/src/tui/event-store.ts +24 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.3.0] - 2026-07-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Recover automatically when an OpenAI-compatible provider completes a response
|
|
13
|
+
with reasoning only and no final answer or tool call: Tinker retries that exact
|
|
14
|
+
request once without adding the invalid response to session history.
|
|
15
|
+
|
|
16
|
+
## [1.2.1] - 2026-07-22
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Ignore unrecognized top-level Agent Skill frontmatter fields while continuing to
|
|
21
|
+
validate Tinker's supported fields, so harmless extensions such as `version` no
|
|
22
|
+
longer prevent the CLI from starting. Unknown fields remain available in the
|
|
23
|
+
original `SKILL.md` content when the skill is activated.
|
|
24
|
+
|
|
8
25
|
## [1.2.0] - 2026-07-21
|
|
9
26
|
|
|
10
27
|
### Added
|
|
@@ -31,6 +48,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
31
48
|
- First formal npm release under the `tinker-agent` package name with the `tinker`
|
|
32
49
|
executable.
|
|
33
50
|
|
|
34
|
-
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.
|
|
51
|
+
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.3.0...HEAD
|
|
52
|
+
[1.3.0]: https://github.com/ishowshao/tinker/releases/tag/v1.3.0
|
|
53
|
+
[1.2.1]: https://github.com/ishowshao/tinker/releases/tag/v1.2.1
|
|
35
54
|
[1.2.0]: https://github.com/ishowshao/tinker/releases/tag/v1.2.0
|
|
36
55
|
[1.1.0]: https://www.npmjs.com/package/tinker-agent/v/1.1.0
|
package/package.json
CHANGED
package/src/agent/loop.ts
CHANGED
|
@@ -2,7 +2,9 @@ import {
|
|
|
2
2
|
materializeModelRequest,
|
|
3
3
|
type MaterializedModelRequest,
|
|
4
4
|
type ModelClient,
|
|
5
|
+
type ModelRequestOutput,
|
|
5
6
|
type PreparedModelRequest,
|
|
7
|
+
ProviderResponseError,
|
|
6
8
|
} from "../model/model-client";
|
|
7
9
|
import type { ImageAssetStore } from "../image/image-asset-store";
|
|
8
10
|
import {
|
|
@@ -83,6 +85,8 @@ export class FatalAgentTurnError extends Error {
|
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
const MODEL_REQUEST_MAX_ATTEMPTS = 2 as const;
|
|
89
|
+
|
|
86
90
|
export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
87
91
|
let lastIteration: IterationIdentity | undefined;
|
|
88
92
|
const committedPrefixAuditor =
|
|
@@ -202,21 +206,15 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
202
206
|
await input.runtimeSession.append({
|
|
203
207
|
type: "model.request.started",
|
|
204
208
|
...iteration,
|
|
205
|
-
data: {
|
|
209
|
+
data: {
|
|
210
|
+
attemptNumber: 1,
|
|
211
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
212
|
+
},
|
|
206
213
|
});
|
|
207
214
|
|
|
208
|
-
let modelOutput;
|
|
209
215
|
try {
|
|
210
216
|
throwIfTurnCancelled(input.signal);
|
|
211
217
|
input.runtimeSession.prepareModelDispatch?.({ iteration, built });
|
|
212
|
-
modelOutput = await input.model.request(request, {
|
|
213
|
-
signal: input.signal,
|
|
214
|
-
identity: {
|
|
215
|
-
iteration,
|
|
216
|
-
runtimeSession: input.runtimeSession,
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
throwIfTurnCancelled(input.signal);
|
|
220
218
|
} catch (error) {
|
|
221
219
|
if (input.signal.aborted) {
|
|
222
220
|
return cancelledResult(
|
|
@@ -228,6 +226,82 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
228
226
|
return failedResult(error, iteration);
|
|
229
227
|
}
|
|
230
228
|
|
|
229
|
+
const requestOptions = {
|
|
230
|
+
signal: input.signal,
|
|
231
|
+
identity: {
|
|
232
|
+
iteration,
|
|
233
|
+
runtimeSession: input.runtimeSession,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
let modelOutput: ModelRequestOutput | undefined;
|
|
237
|
+
let successfulAttempt: 1 | 2 | undefined;
|
|
238
|
+
for (const attemptNumber of [1, 2] as const) {
|
|
239
|
+
if (input.signal.aborted) {
|
|
240
|
+
return cancelledResult(
|
|
241
|
+
cancellation(input.signal, iteration, "model_request"),
|
|
242
|
+
iteration,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (attemptNumber === 2) {
|
|
246
|
+
await input.runtimeSession.append({
|
|
247
|
+
type: "model.request.started",
|
|
248
|
+
...iteration,
|
|
249
|
+
data: {
|
|
250
|
+
attemptNumber,
|
|
251
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
throwIfTurnCancelled(input.signal);
|
|
258
|
+
modelOutput = await input.model.request(request, requestOptions);
|
|
259
|
+
throwIfTurnCancelled(input.signal);
|
|
260
|
+
successfulAttempt = attemptNumber;
|
|
261
|
+
break;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (input.signal.aborted) {
|
|
264
|
+
return cancelledResult(
|
|
265
|
+
cancellation(input.signal, iteration, "model_request"),
|
|
266
|
+
iteration,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const reasoningOnly = isReasoningOnlyProviderError(error);
|
|
271
|
+
const retryDisposition = reasoningOnly
|
|
272
|
+
? attemptNumber === 1
|
|
273
|
+
? "scheduled"
|
|
274
|
+
: "exhausted"
|
|
275
|
+
: "not_retryable";
|
|
276
|
+
await input.runtimeSession.append({
|
|
277
|
+
type: "model.request.failed",
|
|
278
|
+
...iteration,
|
|
279
|
+
data: modelRequestFailureData({
|
|
280
|
+
error,
|
|
281
|
+
request,
|
|
282
|
+
attemptNumber,
|
|
283
|
+
retryDisposition,
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (retryDisposition === "scheduled") {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (retryDisposition === "exhausted") {
|
|
291
|
+
return failedResult(
|
|
292
|
+
new Error(
|
|
293
|
+
`Provider returned reasoning without final text or tool calls in both attempts (provider=${request.provider}, model=${request.model}).`,
|
|
294
|
+
),
|
|
295
|
+
iteration,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return failedResult(error, iteration);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (modelOutput === undefined || successfulAttempt === undefined) {
|
|
302
|
+
throw new Error("Model request attempt state completed without an output.");
|
|
303
|
+
}
|
|
304
|
+
|
|
231
305
|
try {
|
|
232
306
|
input.ledger.appendAssistant({
|
|
233
307
|
iteration,
|
|
@@ -245,7 +319,11 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
|
|
|
245
319
|
await input.runtimeSession.append({
|
|
246
320
|
type: "model.request.finished",
|
|
247
321
|
...iteration,
|
|
248
|
-
data: {
|
|
322
|
+
data: {
|
|
323
|
+
attemptNumber: successfulAttempt,
|
|
324
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
325
|
+
output: modelOutput,
|
|
326
|
+
},
|
|
249
327
|
});
|
|
250
328
|
const measured = input.contextMeter.recordProviderUsage(request, modelOutput);
|
|
251
329
|
await input.runtimeSession.append({
|
|
@@ -617,6 +695,32 @@ function failedResult(
|
|
|
617
695
|
};
|
|
618
696
|
}
|
|
619
697
|
|
|
698
|
+
function isReasoningOnlyProviderError(error: unknown): error is ProviderResponseError {
|
|
699
|
+
return (
|
|
700
|
+
error instanceof ProviderResponseError && error.code === "reasoning_only_assistant"
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function modelRequestFailureData(input: {
|
|
705
|
+
error: unknown;
|
|
706
|
+
request: PreparedModelRequest;
|
|
707
|
+
attemptNumber: 1 | 2;
|
|
708
|
+
retryDisposition: "scheduled" | "not_retryable" | "exhausted";
|
|
709
|
+
}) {
|
|
710
|
+
const providerError =
|
|
711
|
+
input.error instanceof ProviderResponseError ? input.error : undefined;
|
|
712
|
+
return {
|
|
713
|
+
attemptNumber: input.attemptNumber,
|
|
714
|
+
maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
|
|
715
|
+
code: providerError?.code ?? ("provider_request_error" as const),
|
|
716
|
+
retryDisposition: input.retryDisposition,
|
|
717
|
+
provider: input.request.provider,
|
|
718
|
+
model: input.request.model,
|
|
719
|
+
error: errorMessage(input.error),
|
|
720
|
+
...(providerError === undefined ? {} : { diagnostics: providerError.diagnostics }),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
620
724
|
function errorMessage(error: unknown): string {
|
|
621
725
|
return error instanceof Error ? error.message : String(error);
|
|
622
726
|
}
|
|
@@ -45,6 +45,8 @@ export function renderObservationLogEvent(event: AgentEvent): string | undefined
|
|
|
45
45
|
return renderTurnCancelled(event);
|
|
46
46
|
case "turn.failed":
|
|
47
47
|
return renderTurnFailed(event.data.error);
|
|
48
|
+
case "model.request.failed":
|
|
49
|
+
return undefined;
|
|
48
50
|
default:
|
|
49
51
|
return undefined;
|
|
50
52
|
}
|
|
@@ -43,7 +43,13 @@ export class StdoutEventPrinter implements EventSink {
|
|
|
43
43
|
);
|
|
44
44
|
break;
|
|
45
45
|
case "model.request.started":
|
|
46
|
-
|
|
46
|
+
if (event.data.attemptNumber === 1) {
|
|
47
|
+
this.stdout.write(
|
|
48
|
+
`model.request.started iteration=${event.iterationNumber}\n`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
case "model.request.failed":
|
|
47
53
|
break;
|
|
48
54
|
case "model.request.finished":
|
|
49
55
|
this.stdout.write(
|
package/src/events/types.ts
CHANGED
|
@@ -13,7 +13,11 @@ import type {
|
|
|
13
13
|
ToolCallId,
|
|
14
14
|
TurnId,
|
|
15
15
|
} from "../ids/runtime-id";
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
ModelRequestOutput,
|
|
18
|
+
ProviderResponseDiagnostics,
|
|
19
|
+
ProviderResponseErrorCode,
|
|
20
|
+
} from "../model/model-client";
|
|
17
21
|
import type {
|
|
18
22
|
ModelContextBudget,
|
|
19
23
|
ModelContextProfile,
|
|
@@ -280,6 +284,22 @@ export type TurnFinishedData = {
|
|
|
280
284
|
messageCount: number;
|
|
281
285
|
};
|
|
282
286
|
|
|
287
|
+
export type ModelRequestAttemptData = {
|
|
288
|
+
attemptNumber: 1 | 2;
|
|
289
|
+
maxAttempts: 2;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export type ModelRequestFailureCode = ProviderResponseErrorCode;
|
|
293
|
+
|
|
294
|
+
export type ModelRequestFailedData = ModelRequestAttemptData & {
|
|
295
|
+
code: ModelRequestFailureCode;
|
|
296
|
+
retryDisposition: "scheduled" | "not_retryable" | "exhausted";
|
|
297
|
+
provider: string;
|
|
298
|
+
model: string;
|
|
299
|
+
error: string;
|
|
300
|
+
diagnostics?: ProviderResponseDiagnostics;
|
|
301
|
+
};
|
|
302
|
+
|
|
283
303
|
export type AgentEventDataMap = {
|
|
284
304
|
"session.started": SessionStartedData;
|
|
285
305
|
"session.resumed": SessionResumedData;
|
|
@@ -290,8 +310,11 @@ export type AgentEventDataMap = {
|
|
|
290
310
|
"turn.failed": { error: string };
|
|
291
311
|
"turn.cancelled": { cancellation: TurnCancellation };
|
|
292
312
|
"agent.iteration.started": { iterationNumber: number };
|
|
293
|
-
"model.request.started":
|
|
294
|
-
"model.request.
|
|
313
|
+
"model.request.started": ModelRequestAttemptData;
|
|
314
|
+
"model.request.failed": ModelRequestFailedData;
|
|
315
|
+
"model.request.finished": ModelRequestAttemptData & {
|
|
316
|
+
output: ModelRequestOutput;
|
|
317
|
+
};
|
|
295
318
|
"context.usage.updated": ContextUsageUpdatedData;
|
|
296
319
|
"context.revision.started": ContextRevisionStartedData;
|
|
297
320
|
"context.revision.finished": ContextRevisionFinishedData;
|
|
@@ -385,6 +408,7 @@ export type AgentEventInput =
|
|
|
385
408
|
| IterationEventInput<
|
|
386
409
|
| "agent.iteration.started"
|
|
387
410
|
| "model.request.started"
|
|
411
|
+
| "model.request.failed"
|
|
388
412
|
| "model.request.finished"
|
|
389
413
|
| "context.usage.updated"
|
|
390
414
|
| "context.shadow.planned"
|
|
@@ -122,3 +122,32 @@ export type ModelUsage = {
|
|
|
122
122
|
promptCacheMissTokens?: number;
|
|
123
123
|
reasoningTokens?: number;
|
|
124
124
|
};
|
|
125
|
+
|
|
126
|
+
export type ProviderResponseErrorCode =
|
|
127
|
+
| "reasoning_only_assistant"
|
|
128
|
+
| "invalid_provider_response"
|
|
129
|
+
| "invalid_provider_stream"
|
|
130
|
+
| "provider_request_error";
|
|
131
|
+
|
|
132
|
+
export type ProviderResponseDiagnostics = {
|
|
133
|
+
provider: string;
|
|
134
|
+
model: string;
|
|
135
|
+
path?: string;
|
|
136
|
+
finishReason?: string;
|
|
137
|
+
contentChars?: number;
|
|
138
|
+
reasoningChars?: number;
|
|
139
|
+
toolCallCount?: number;
|
|
140
|
+
usage?: ModelUsage;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export class ProviderResponseError extends Error {
|
|
144
|
+
constructor(
|
|
145
|
+
readonly code: ProviderResponseErrorCode,
|
|
146
|
+
message: string,
|
|
147
|
+
readonly diagnostics: ProviderResponseDiagnostics,
|
|
148
|
+
options?: ErrorOptions,
|
|
149
|
+
) {
|
|
150
|
+
super(message, options);
|
|
151
|
+
this.name = "ProviderResponseError";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -5,7 +5,12 @@ import type {
|
|
|
5
5
|
UserMessage,
|
|
6
6
|
} from "../agent/types";
|
|
7
7
|
import type { RuntimeSessionContext } from "../agent/runtime-session";
|
|
8
|
-
import
|
|
8
|
+
import {
|
|
9
|
+
ProviderResponseError,
|
|
10
|
+
type ModelRequestOutput,
|
|
11
|
+
type ModelUsage,
|
|
12
|
+
type ProviderResponseDiagnostics,
|
|
13
|
+
} from "./model-client";
|
|
9
14
|
import type { ToolDefinition } from "../tools/types";
|
|
10
15
|
import type {
|
|
11
16
|
ChatCompletionAssistantMessageParam,
|
|
@@ -178,21 +183,45 @@ export function fromOpenAIChatCompletion(
|
|
|
178
183
|
"choices[0].message.content",
|
|
179
184
|
options,
|
|
180
185
|
);
|
|
186
|
+
const finishReason = optionalString(
|
|
187
|
+
choice.finish_reason,
|
|
188
|
+
"choices[0].finish_reason",
|
|
189
|
+
options,
|
|
190
|
+
);
|
|
191
|
+
const usage = parseUsage(completion.usage, options);
|
|
192
|
+
const reasoningContent = normalizeContent(
|
|
193
|
+
message.reasoning_content,
|
|
194
|
+
"choices[0].message.reasoning_content",
|
|
195
|
+
options,
|
|
196
|
+
);
|
|
197
|
+
const diagnostics = responseDiagnostics(options, {
|
|
198
|
+
path: "choices[0].message",
|
|
199
|
+
finishReason,
|
|
200
|
+
contentChars: content?.length ?? 0,
|
|
201
|
+
reasoningChars: reasoningContent?.length ?? 0,
|
|
202
|
+
toolCallCount: rawToolCalls.length,
|
|
203
|
+
usage,
|
|
204
|
+
});
|
|
181
205
|
if ((content === null || content.trim() === "") && rawToolCalls.length === 0) {
|
|
206
|
+
if (
|
|
207
|
+
finishReason === "stop" &&
|
|
208
|
+
reasoningContent !== null &&
|
|
209
|
+
reasoningContent.trim() !== ""
|
|
210
|
+
) {
|
|
211
|
+
throw new ProviderResponseError(
|
|
212
|
+
"reasoning_only_assistant",
|
|
213
|
+
`Invalid provider response (provider=${options.provider}, model=${options.model}): choices[0].message contains reasoning but neither non-empty final text nor tool calls.`,
|
|
214
|
+
diagnostics,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
182
217
|
throw providerResponseError(
|
|
183
218
|
options,
|
|
184
219
|
"choices[0].message",
|
|
185
220
|
"has neither non-empty text nor tool calls",
|
|
221
|
+
diagnostics,
|
|
186
222
|
);
|
|
187
223
|
}
|
|
188
224
|
|
|
189
|
-
const finishReason = optionalString(
|
|
190
|
-
choice.finish_reason,
|
|
191
|
-
"choices[0].finish_reason",
|
|
192
|
-
options,
|
|
193
|
-
);
|
|
194
|
-
const usage = parseUsage(completion.usage, options);
|
|
195
|
-
|
|
196
225
|
if (rawToolCalls.length > 0 && options.identity === undefined) {
|
|
197
226
|
throw providerResponseError(
|
|
198
227
|
options,
|
|
@@ -208,11 +237,7 @@ export function fromOpenAIChatCompletion(
|
|
|
208
237
|
message: {
|
|
209
238
|
role: "assistant",
|
|
210
239
|
content,
|
|
211
|
-
reasoningContent
|
|
212
|
-
message.reasoning_content,
|
|
213
|
-
"choices[0].message.reasoning_content",
|
|
214
|
-
options,
|
|
215
|
-
),
|
|
240
|
+
reasoningContent,
|
|
216
241
|
toolCalls: toolCalls.length === 0 ? undefined : toolCalls,
|
|
217
242
|
},
|
|
218
243
|
finishReason,
|
|
@@ -513,8 +538,22 @@ function providerResponseError(
|
|
|
513
538
|
options: { provider: string; model: string },
|
|
514
539
|
path: string,
|
|
515
540
|
detail: string,
|
|
516
|
-
|
|
517
|
-
|
|
541
|
+
diagnostics: ProviderResponseDiagnostics = responseDiagnostics(options, { path }),
|
|
542
|
+
): ProviderResponseError {
|
|
543
|
+
return new ProviderResponseError(
|
|
544
|
+
"invalid_provider_response",
|
|
518
545
|
`Invalid provider response (provider=${options.provider}, model=${options.model}): ${path} ${detail}.`,
|
|
546
|
+
{ ...diagnostics, path },
|
|
519
547
|
);
|
|
520
548
|
}
|
|
549
|
+
|
|
550
|
+
function responseDiagnostics(
|
|
551
|
+
options: { provider: string; model: string },
|
|
552
|
+
diagnostics: Omit<ProviderResponseDiagnostics, "provider" | "model">,
|
|
553
|
+
): ProviderResponseDiagnostics {
|
|
554
|
+
return {
|
|
555
|
+
provider: options.provider,
|
|
556
|
+
model: options.model,
|
|
557
|
+
...diagnostics,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
|
|
12
12
|
import type { ModelContextBudget } from "./model-context-profile";
|
|
13
13
|
import type { InputTokenEstimator } from "./input-token-estimator";
|
|
14
|
-
import { ModelRequestMediaAggregateError } from "./model-client";
|
|
14
|
+
import { ModelRequestMediaAggregateError, ProviderResponseError } from "./model-client";
|
|
15
15
|
import type {
|
|
16
16
|
MaterializedModelRequest,
|
|
17
17
|
ModelClient,
|
|
@@ -234,17 +234,12 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
234
234
|
throw new Error("Image request must be materialized before provider dispatch.");
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
{ signal: options.signal },
|
|
244
|
-
);
|
|
245
|
-
} catch (error) {
|
|
246
|
-
throw sanitizedProviderError(error);
|
|
247
|
-
}
|
|
237
|
+
const response = this.stream
|
|
238
|
+
? accumulateOpenAIChatCompletionChunks(
|
|
239
|
+
await this.collectStreamingChunks(prepared, options.signal),
|
|
240
|
+
{ provider: this.provider, model: this.options.model },
|
|
241
|
+
)
|
|
242
|
+
: await this.requestNonStreaming(prepared, options.signal);
|
|
248
243
|
|
|
249
244
|
return fromOpenAIChatCompletion(response, {
|
|
250
245
|
identity: options.identity,
|
|
@@ -253,22 +248,37 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
253
248
|
});
|
|
254
249
|
}
|
|
255
250
|
|
|
256
|
-
private async
|
|
251
|
+
private async collectStreamingChunks(
|
|
257
252
|
prepared: PreparedModelRequest,
|
|
258
253
|
signal: AbortSignal,
|
|
259
|
-
): Promise<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
254
|
+
): Promise<unknown[]> {
|
|
255
|
+
try {
|
|
256
|
+
const stream = await this.client.chat.completions.create(
|
|
257
|
+
prepared.payload as ChatCompletionCreateParamsStreaming,
|
|
258
|
+
{ signal },
|
|
259
|
+
);
|
|
260
|
+
const chunks: unknown[] = [];
|
|
261
|
+
for await (const chunk of stream) {
|
|
262
|
+
chunks.push(chunk);
|
|
263
|
+
}
|
|
264
|
+
return chunks;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
throw sanitizedProviderError(error, this.provider, this.options.model);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async requestNonStreaming(
|
|
271
|
+
prepared: PreparedModelRequest,
|
|
272
|
+
signal: AbortSignal,
|
|
273
|
+
) {
|
|
274
|
+
try {
|
|
275
|
+
return await this.client.chat.completions.create(
|
|
276
|
+
prepared.payload as ChatCompletionCreateParamsNonStreaming,
|
|
277
|
+
{ signal },
|
|
278
|
+
);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
throw sanitizedProviderError(error, this.provider, this.options.model);
|
|
267
281
|
}
|
|
268
|
-
return accumulateOpenAIChatCompletionChunks(chunks, {
|
|
269
|
-
provider: this.provider,
|
|
270
|
-
model: this.options.model,
|
|
271
|
-
});
|
|
272
282
|
}
|
|
273
283
|
|
|
274
284
|
private assistantReplaySegments(message: AssistantMessage): PreparedPromptSegment[] {
|
|
@@ -508,7 +518,11 @@ function deepFreeze<T>(value: T): T {
|
|
|
508
518
|
return Object.freeze(value);
|
|
509
519
|
}
|
|
510
520
|
|
|
511
|
-
function sanitizedProviderError(
|
|
521
|
+
function sanitizedProviderError(
|
|
522
|
+
error: unknown,
|
|
523
|
+
provider: string,
|
|
524
|
+
model: string,
|
|
525
|
+
): ProviderResponseError {
|
|
512
526
|
const message = error instanceof Error ? error.message : String(error);
|
|
513
527
|
const sanitized = message
|
|
514
528
|
.replace(
|
|
@@ -516,5 +530,10 @@ function sanitizedProviderError(error: unknown): Error {
|
|
|
516
530
|
"[redacted image data]",
|
|
517
531
|
)
|
|
518
532
|
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+/giu, "Bearer [redacted]");
|
|
519
|
-
return new
|
|
533
|
+
return new ProviderResponseError(
|
|
534
|
+
"provider_request_error",
|
|
535
|
+
sanitized,
|
|
536
|
+
{ provider, model },
|
|
537
|
+
{ cause: error },
|
|
538
|
+
);
|
|
520
539
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ProviderResponseError } from "./model-client";
|
|
2
|
+
|
|
1
3
|
type ProviderContext = { provider: string; model: string };
|
|
2
4
|
|
|
3
5
|
type ToolCallAccumulator = {
|
|
@@ -265,8 +267,10 @@ function providerStreamError(
|
|
|
265
267
|
options: ProviderContext,
|
|
266
268
|
path: string,
|
|
267
269
|
detail: string,
|
|
268
|
-
):
|
|
269
|
-
return new
|
|
270
|
+
): ProviderResponseError {
|
|
271
|
+
return new ProviderResponseError(
|
|
272
|
+
"invalid_provider_stream",
|
|
270
273
|
`Invalid provider stream (provider=${options.provider}, model=${options.model}): ${path} ${detail}.`,
|
|
274
|
+
{ provider: options.provider, model: options.model, path },
|
|
271
275
|
);
|
|
272
276
|
}
|
|
@@ -86,15 +86,6 @@ type ScopeRoot = {
|
|
|
86
86
|
canonicalRoot: string;
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
-
const SKILL_FIELDS = Object.freeze([
|
|
90
|
-
"name",
|
|
91
|
-
"description",
|
|
92
|
-
"license",
|
|
93
|
-
"compatibility",
|
|
94
|
-
"metadata",
|
|
95
|
-
"allowed-tools",
|
|
96
|
-
] as const);
|
|
97
|
-
|
|
98
89
|
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
99
90
|
|
|
100
91
|
export async function loadSkillCatalog(input: {
|
|
@@ -519,17 +510,6 @@ function parseSkillDocument(
|
|
|
519
510
|
if (!isPlainRecord(value)) {
|
|
520
511
|
throw frontmatterError(scope, directoryName, "root must be a plain mapping");
|
|
521
512
|
}
|
|
522
|
-
const unknown = Object.keys(value).filter(
|
|
523
|
-
(key) => !(SKILL_FIELDS as readonly string[]).includes(key),
|
|
524
|
-
);
|
|
525
|
-
if (unknown.length > 0) {
|
|
526
|
-
throw fieldError(
|
|
527
|
-
scope,
|
|
528
|
-
directoryName,
|
|
529
|
-
`contains unknown field ${JSON.stringify(unknown.sort(compareText)[0])}`,
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
513
|
const name = requireStringField(value, "name", scope, directoryName);
|
|
534
514
|
const description = requireStringField(value, "description", scope, directoryName);
|
|
535
515
|
if (
|
package/src/tui/event-store.ts
CHANGED
|
@@ -153,13 +153,21 @@ export function reduceTuiProjection(
|
|
|
153
153
|
}
|
|
154
154
|
case "model.request.started":
|
|
155
155
|
return updateActiveTurn(state, event, policy, (turn) =>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
event.data.attemptNumber === 1
|
|
157
|
+
? appendTurnItem(turn, {
|
|
158
|
+
id: `model-${event.iterationId}`,
|
|
159
|
+
ref: modelRequestRef(event.iterationId),
|
|
160
|
+
text: `model iteration ${event.iterationNumber}`,
|
|
161
|
+
status: "running",
|
|
162
|
+
})
|
|
163
|
+
: updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
|
|
164
|
+
...item,
|
|
165
|
+
text: `model iteration ${event.iterationNumber} · retrying`,
|
|
166
|
+
status: "running",
|
|
167
|
+
})),
|
|
162
168
|
);
|
|
169
|
+
case "model.request.failed":
|
|
170
|
+
return state;
|
|
163
171
|
case "model.request.finished":
|
|
164
172
|
return updateActiveTurn(state, event, policy, (turn) =>
|
|
165
173
|
updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
|
|
@@ -333,7 +341,7 @@ export function reduceTuiProjection(
|
|
|
333
341
|
}
|
|
334
342
|
case "turn.failed": {
|
|
335
343
|
const active = requireActiveTurn(state, event);
|
|
336
|
-
const failed = appendTurnItem(active, {
|
|
344
|
+
const failed = appendTurnItem(markRunningItemsFailed(active), {
|
|
337
345
|
id: `turn-${active.turnId}-failed-${event.eventSequence}`,
|
|
338
346
|
label: "error",
|
|
339
347
|
text: event.data.error,
|
|
@@ -467,6 +475,15 @@ function appendTurnItem(
|
|
|
467
475
|
return { ...turn, items: [...turn.items, item] };
|
|
468
476
|
}
|
|
469
477
|
|
|
478
|
+
function markRunningItemsFailed(turn: TuiTurnProjection): TuiTurnProjection {
|
|
479
|
+
return {
|
|
480
|
+
...turn,
|
|
481
|
+
items: turn.items.map((item) =>
|
|
482
|
+
item.status === "running" ? { ...item, status: "failed" } : item,
|
|
483
|
+
),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
470
487
|
function updateTurnItem(
|
|
471
488
|
turn: TuiTurnProjection,
|
|
472
489
|
ref: string,
|