@jsonstudio/llms 0.6.567 → 0.6.586
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/dist/conversion/codecs/gemini-openai-codec.js +33 -4
- package/dist/conversion/codecs/openai-openai-codec.js +2 -1
- package/dist/conversion/codecs/responses-openai-codec.js +3 -2
- package/dist/conversion/compat/actions/glm-history-image-trim.d.ts +2 -0
- package/dist/conversion/compat/actions/glm-history-image-trim.js +88 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +6 -2
- package/dist/conversion/hub/pipeline/hub-pipeline.js +72 -81
- package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +0 -34
- package/dist/conversion/hub/process/chat-process.js +68 -24
- package/dist/conversion/hub/response/provider-response.js +0 -8
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +22 -3
- package/dist/conversion/hub/semantic-mappers/responses-mapper.js +267 -14
- package/dist/conversion/hub/types/chat-envelope.d.ts +1 -0
- package/dist/conversion/responses/responses-openai-bridge.d.ts +3 -2
- package/dist/conversion/responses/responses-openai-bridge.js +1 -13
- package/dist/conversion/shared/anthropic-message-utils.js +54 -0
- package/dist/conversion/shared/args-mapping.js +11 -3
- package/dist/conversion/shared/responses-output-builder.js +42 -21
- package/dist/conversion/shared/streaming-text-extractor.d.ts +25 -0
- package/dist/conversion/shared/streaming-text-extractor.js +31 -38
- package/dist/conversion/shared/text-markup-normalizer.d.ts +20 -0
- package/dist/conversion/shared/text-markup-normalizer.js +118 -31
- package/dist/conversion/shared/tool-filter-pipeline.js +56 -30
- package/dist/conversion/shared/tool-harvester.js +43 -12
- package/dist/conversion/shared/tool-mapping.d.ts +1 -0
- package/dist/conversion/shared/tool-mapping.js +33 -19
- package/dist/filters/index.d.ts +1 -0
- package/dist/filters/index.js +1 -0
- package/dist/filters/special/request-tools-normalize.js +14 -4
- package/dist/filters/special/response-apply-patch-toon-decode.d.ts +23 -0
- package/dist/filters/special/response-apply-patch-toon-decode.js +117 -0
- package/dist/filters/special/response-tool-arguments-toon-decode.d.ts +10 -0
- package/dist/filters/special/response-tool-arguments-toon-decode.js +154 -26
- package/dist/guidance/index.js +71 -42
- package/dist/router/virtual-router/bootstrap.js +10 -5
- package/dist/router/virtual-router/classifier.js +16 -7
- package/dist/router/virtual-router/engine-health.d.ts +11 -0
- package/dist/router/virtual-router/engine-health.js +217 -4
- package/dist/router/virtual-router/engine-logging.d.ts +2 -1
- package/dist/router/virtual-router/engine-logging.js +35 -3
- package/dist/router/virtual-router/engine.d.ts +17 -1
- package/dist/router/virtual-router/engine.js +184 -6
- package/dist/router/virtual-router/routing-instructions.d.ts +2 -0
- package/dist/router/virtual-router/routing-instructions.js +19 -1
- package/dist/router/virtual-router/tool-signals.d.ts +2 -1
- package/dist/router/virtual-router/tool-signals.js +324 -119
- package/dist/router/virtual-router/types.d.ts +31 -1
- package/dist/router/virtual-router/types.js +2 -2
- package/dist/servertool/engine.js +3 -0
- package/dist/servertool/handlers/iflow-model-error-retry.d.ts +1 -0
- package/dist/servertool/handlers/iflow-model-error-retry.js +93 -0
- package/dist/servertool/handlers/stop-message-auto.js +61 -4
- package/dist/servertool/server-side-tools.d.ts +1 -0
- package/dist/servertool/server-side-tools.js +27 -0
- package/dist/sse/json-to-sse/event-generators/responses.js +9 -2
- package/dist/sse/sse-to-json/builders/anthropic-response-builder.js +23 -3
- package/dist/tools/apply-patch-structured.d.ts +20 -0
- package/dist/tools/apply-patch-structured.js +240 -0
- package/dist/tools/tool-description-utils.d.ts +5 -0
- package/dist/tools/tool-description-utils.js +50 -0
- package/dist/tools/tool-registry.js +11 -193
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { registerServerToolHandler } from '../registry.js';
|
|
2
|
+
import { cloneJson } from '../server-side-tools.js';
|
|
3
|
+
const FLOW_ID = 'iflow_model_error_retry';
|
|
4
|
+
const handler = async (ctx) => {
|
|
5
|
+
if (!ctx.options.reenterPipeline) {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
const adapterRecord = ctx.adapterContext;
|
|
9
|
+
// 避免在 followup 请求里再次触发,防止循环。
|
|
10
|
+
const followupRaw = adapterRecord.serverToolFollowup;
|
|
11
|
+
if (followupRaw === true || (typeof followupRaw === 'string' && followupRaw.trim().toLowerCase() === 'true')) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
// 仅针对 openai-chat 协议 + iflow.* providerKey 的 /v1/responses 路径启用。
|
|
15
|
+
if (ctx.options.providerProtocol !== 'openai-chat') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const entryEndpoint = (ctx.options.entryEndpoint || '').toLowerCase();
|
|
19
|
+
if (!entryEndpoint.includes('/v1/responses')) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const providerKey = typeof adapterRecord.providerKey === 'string' && adapterRecord.providerKey.trim()
|
|
23
|
+
? adapterRecord.providerKey.trim().toLowerCase()
|
|
24
|
+
: '';
|
|
25
|
+
if (!providerKey.startsWith('iflow.')) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// 仅在上游返回 error_code(HTTP 200 + 业务错误)时触发一次自动重试。
|
|
29
|
+
const base = ctx.base;
|
|
30
|
+
const errorCode = base.error_code;
|
|
31
|
+
const msg = base.msg;
|
|
32
|
+
if (typeof errorCode !== 'number' || errorCode === 0) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (typeof msg !== 'string' || !msg.trim().length) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const captured = getCapturedRequest(ctx.adapterContext);
|
|
39
|
+
if (!captured) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const followupPayload = buildRetryFollowupPayload(captured);
|
|
43
|
+
if (!followupPayload) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
chatResponse: ctx.base,
|
|
48
|
+
execution: {
|
|
49
|
+
flowId: FLOW_ID,
|
|
50
|
+
followup: {
|
|
51
|
+
requestIdSuffix: ':retry',
|
|
52
|
+
payload: followupPayload,
|
|
53
|
+
metadata: {
|
|
54
|
+
serverToolFollowup: true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
registerServerToolHandler('iflow_model_error_retry', handler, { trigger: 'auto' });
|
|
61
|
+
function getCapturedRequest(adapterContext) {
|
|
62
|
+
if (!adapterContext || typeof adapterContext !== 'object') {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const captured = adapterContext.capturedChatRequest;
|
|
66
|
+
if (!captured || typeof captured !== 'object' || Array.isArray(captured)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return captured;
|
|
70
|
+
}
|
|
71
|
+
function buildRetryFollowupPayload(source) {
|
|
72
|
+
if (!source || typeof source !== 'object') {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const payload = {};
|
|
76
|
+
if (typeof source.model === 'string' && source.model.trim()) {
|
|
77
|
+
payload.model = source.model.trim();
|
|
78
|
+
}
|
|
79
|
+
const rawMessages = source.messages;
|
|
80
|
+
if (Array.isArray(rawMessages)) {
|
|
81
|
+
payload.messages = cloneJson(rawMessages);
|
|
82
|
+
}
|
|
83
|
+
const rawTools = source.tools;
|
|
84
|
+
if (Array.isArray(rawTools) && rawTools.length) {
|
|
85
|
+
payload.tools = cloneJson(rawTools);
|
|
86
|
+
}
|
|
87
|
+
const parameters = source.parameters;
|
|
88
|
+
if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) {
|
|
89
|
+
const params = cloneJson(parameters);
|
|
90
|
+
Object.assign(payload, params);
|
|
91
|
+
}
|
|
92
|
+
return payload;
|
|
93
|
+
}
|
|
@@ -1,24 +1,52 @@
|
|
|
1
1
|
import { registerServerToolHandler } from '../registry.js';
|
|
2
2
|
import { cloneJson } from '../server-side-tools.js';
|
|
3
3
|
import { loadRoutingInstructionStateSync, saveRoutingInstructionStateAsync } from '../../router/virtual-router/sticky-session-store.js';
|
|
4
|
+
const STOPMESSAGE_DEBUG = (process.env.ROUTECODEX_STOPMESSAGE_DEBUG || '').trim() === '1';
|
|
5
|
+
function debugLog(message, extra) {
|
|
6
|
+
if (!STOPMESSAGE_DEBUG) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
// eslint-disable-next-line no-console
|
|
11
|
+
console.log(`\x1b[38;5;33m[stopMessage][debug] ${message}` +
|
|
12
|
+
(extra ? ` ${JSON.stringify(extra)}` : '') +
|
|
13
|
+
'\x1b[0m');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
/* ignore logging failures */
|
|
17
|
+
}
|
|
18
|
+
}
|
|
4
19
|
const FLOW_ID = 'stop_message_flow';
|
|
5
20
|
const handler = async (ctx) => {
|
|
6
21
|
const record = ctx.adapterContext;
|
|
22
|
+
debugLog('handler_start', {
|
|
23
|
+
requestId: record.requestId,
|
|
24
|
+
providerProtocol: record.providerProtocol
|
|
25
|
+
});
|
|
7
26
|
const followupRaw = record.serverToolFollowup;
|
|
8
27
|
if (followupRaw === true || (typeof followupRaw === 'string' && followupRaw.trim().toLowerCase() === 'true')) {
|
|
28
|
+
debugLog('skip_servertool_followup_flag');
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const connectionState = resolveClientConnectionState(record.clientConnectionState);
|
|
32
|
+
if (connectionState?.disconnected === true) {
|
|
33
|
+
debugLog('skip_client_disconnected');
|
|
9
34
|
return null;
|
|
10
35
|
}
|
|
11
36
|
const clientDisconnectedRaw = record.clientDisconnected;
|
|
12
37
|
if (clientDisconnectedRaw === true ||
|
|
13
38
|
(typeof clientDisconnectedRaw === 'string' && clientDisconnectedRaw.trim().toLowerCase() === 'true')) {
|
|
39
|
+
debugLog('skip_client_disconnected_flag');
|
|
14
40
|
return null;
|
|
15
41
|
}
|
|
16
42
|
const stickyKey = resolveStickyKey(record);
|
|
17
43
|
if (!stickyKey) {
|
|
44
|
+
debugLog('skip_no_sticky_key');
|
|
18
45
|
return null;
|
|
19
46
|
}
|
|
20
47
|
const state = loadRoutingInstructionStateSync(stickyKey);
|
|
21
48
|
if (!state || !state.stopMessageText || !state.stopMessageMaxRepeats) {
|
|
49
|
+
debugLog('skip_no_state', { stickyKey });
|
|
22
50
|
return null;
|
|
23
51
|
}
|
|
24
52
|
const text = typeof state.stopMessageText === 'string' ? state.stopMessageText.trim() : '';
|
|
@@ -26,25 +54,45 @@ const handler = async (ctx) => {
|
|
|
26
54
|
? Math.max(1, Math.floor(state.stopMessageMaxRepeats))
|
|
27
55
|
: 0;
|
|
28
56
|
if (!text || maxRepeats <= 0) {
|
|
57
|
+
debugLog('skip_invalid_text_or_maxRepeats', {
|
|
58
|
+
stickyKey,
|
|
59
|
+
textLength: text.length,
|
|
60
|
+
maxRepeats
|
|
61
|
+
});
|
|
29
62
|
return null;
|
|
30
63
|
}
|
|
31
64
|
const used = typeof state.stopMessageUsed === 'number' && Number.isFinite(state.stopMessageUsed)
|
|
32
65
|
? Math.max(0, Math.floor(state.stopMessageUsed))
|
|
33
66
|
: 0;
|
|
34
67
|
if (used >= maxRepeats) {
|
|
68
|
+
debugLog('skip_reached_max_repeats', {
|
|
69
|
+
stickyKey,
|
|
70
|
+
used,
|
|
71
|
+
maxRepeats
|
|
72
|
+
});
|
|
35
73
|
return null;
|
|
36
74
|
}
|
|
37
75
|
if (!isStopFinishReason(ctx.base)) {
|
|
76
|
+
debugLog('skip_not_stop_finish_reason', {
|
|
77
|
+
stickyKey
|
|
78
|
+
});
|
|
38
79
|
return null;
|
|
39
80
|
}
|
|
40
81
|
const captured = getCapturedRequest(ctx.adapterContext);
|
|
41
82
|
if (!captured) {
|
|
83
|
+
debugLog('skip_no_captured_request', {
|
|
84
|
+
stickyKey
|
|
85
|
+
});
|
|
42
86
|
return null;
|
|
43
87
|
}
|
|
44
88
|
state.stopMessageUsed = used + 1;
|
|
89
|
+
state.stopMessageLastUsedAt = Date.now();
|
|
45
90
|
saveRoutingInstructionStateAsync(stickyKey, state);
|
|
46
91
|
const followupPayload = buildStopMessageFollowupPayload(captured, text);
|
|
47
92
|
if (!followupPayload) {
|
|
93
|
+
debugLog('skip_failed_build_followup', {
|
|
94
|
+
stickyKey
|
|
95
|
+
});
|
|
48
96
|
return null;
|
|
49
97
|
}
|
|
50
98
|
return {
|
|
@@ -56,7 +104,10 @@ const handler = async (ctx) => {
|
|
|
56
104
|
payload: followupPayload,
|
|
57
105
|
metadata: {
|
|
58
106
|
serverToolFollowup: true,
|
|
59
|
-
stream: false
|
|
107
|
+
stream: false,
|
|
108
|
+
preserveRouteHint: false,
|
|
109
|
+
disableStickyRoutes: true,
|
|
110
|
+
...(connectionState ? { clientConnectionState: connectionState } : {})
|
|
60
111
|
}
|
|
61
112
|
}
|
|
62
113
|
}
|
|
@@ -65,12 +116,12 @@ const handler = async (ctx) => {
|
|
|
65
116
|
registerServerToolHandler('stop_message_auto', handler, { trigger: 'auto' });
|
|
66
117
|
function resolveStickyKey(record) {
|
|
67
118
|
const sessionId = typeof record.sessionId === 'string' && record.sessionId.trim() ? record.sessionId.trim() : '';
|
|
68
|
-
if (sessionId) {
|
|
69
|
-
return `session:${sessionId}`;
|
|
70
|
-
}
|
|
71
119
|
const conversationId = typeof record.conversationId === 'string' && record.conversationId.trim()
|
|
72
120
|
? record.conversationId.trim()
|
|
73
121
|
: '';
|
|
122
|
+
if (sessionId) {
|
|
123
|
+
return `session:${sessionId}`;
|
|
124
|
+
}
|
|
74
125
|
if (conversationId) {
|
|
75
126
|
return `conversation:${conversationId}`;
|
|
76
127
|
}
|
|
@@ -145,3 +196,9 @@ function buildStopMessageFollowupPayload(source, text) {
|
|
|
145
196
|
}
|
|
146
197
|
return payload;
|
|
147
198
|
}
|
|
199
|
+
function resolveClientConnectionState(value) {
|
|
200
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { ServerSideToolEngineOptions, ServerSideToolEngineResult, ToolCall
|
|
|
3
3
|
import './handlers/web-search.js';
|
|
4
4
|
import './handlers/vision.js';
|
|
5
5
|
import './handlers/gemini-empty-reply-continue.js';
|
|
6
|
+
import './handlers/iflow-model-error-retry.js';
|
|
6
7
|
import './handlers/stop-message-auto.js';
|
|
7
8
|
export declare function runServerSideToolEngine(options: ServerSideToolEngineOptions): Promise<ServerSideToolEngineResult>;
|
|
8
9
|
export declare function extractToolCalls(chatResponse: JsonObject): ToolCall[];
|
|
@@ -2,12 +2,16 @@ import { getServerToolHandler, listAutoServerToolHandlers } from './registry.js'
|
|
|
2
2
|
import './handlers/web-search.js';
|
|
3
3
|
import './handlers/vision.js';
|
|
4
4
|
import './handlers/gemini-empty-reply-continue.js';
|
|
5
|
+
import './handlers/iflow-model-error-retry.js';
|
|
5
6
|
import './handlers/stop-message-auto.js';
|
|
6
7
|
export async function runServerSideToolEngine(options) {
|
|
7
8
|
const base = asObject(options.chatResponse);
|
|
8
9
|
if (!base) {
|
|
9
10
|
return { mode: 'passthrough', finalChatResponse: options.chatResponse };
|
|
10
11
|
}
|
|
12
|
+
if (isClientDisconnected(options.adapterContext)) {
|
|
13
|
+
return { mode: 'passthrough', finalChatResponse: base };
|
|
14
|
+
}
|
|
11
15
|
const toolCalls = extractToolCalls(base);
|
|
12
16
|
const contextBase = {
|
|
13
17
|
base,
|
|
@@ -91,6 +95,29 @@ function getArray(value) {
|
|
|
91
95
|
export function cloneJson(value) {
|
|
92
96
|
return JSON.parse(JSON.stringify(value));
|
|
93
97
|
}
|
|
98
|
+
function isClientDisconnected(adapterContext) {
|
|
99
|
+
if (!adapterContext || typeof adapterContext !== 'object') {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const state = adapterContext.clientConnectionState;
|
|
103
|
+
if (state && typeof state === 'object' && !Array.isArray(state)) {
|
|
104
|
+
const disconnected = state.disconnected;
|
|
105
|
+
if (disconnected === true) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
if (typeof disconnected === 'string' && disconnected.trim().toLowerCase() === 'true') {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const raw = adapterContext.clientDisconnected;
|
|
113
|
+
if (raw === true) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (typeof raw === 'string' && raw.trim().toLowerCase() === 'true') {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
94
121
|
export function extractTextFromChatLike(payload) {
|
|
95
122
|
let current = payload;
|
|
96
123
|
const visited = new Set();
|
|
@@ -81,9 +81,16 @@ function normalizeUsage(usage) {
|
|
|
81
81
|
return fallback;
|
|
82
82
|
}
|
|
83
83
|
const asAny = usage;
|
|
84
|
-
const
|
|
84
|
+
const baseInputRaw = Number((asAny.input_tokens ?? asAny.prompt_tokens));
|
|
85
|
+
const baseInput = Number.isFinite(baseInputRaw) ? baseInputRaw : 0;
|
|
86
|
+
let cachedRaw = Number(asAny.cache_read_input_tokens);
|
|
87
|
+
if (!Number.isFinite(cachedRaw) && asAny.input_tokens_details && typeof asAny.input_tokens_details === 'object') {
|
|
88
|
+
const details = asAny.input_tokens_details;
|
|
89
|
+
cachedRaw = Number(details.cached_tokens);
|
|
90
|
+
}
|
|
91
|
+
const cached = Number.isFinite(cachedRaw) ? cachedRaw : 0;
|
|
92
|
+
const input = baseInput + cached;
|
|
85
93
|
const outputRaw = Number((asAny.output_tokens ?? asAny.completion_tokens));
|
|
86
|
-
const input = Number.isFinite(inputRaw) ? inputRaw : 0;
|
|
87
94
|
const output = Number.isFinite(outputRaw) ? outputRaw : 0;
|
|
88
95
|
const totalRaw = Number(asAny.total_tokens);
|
|
89
96
|
const total = Number.isFinite(totalRaw) ? totalRaw : input + output;
|
|
@@ -111,12 +111,16 @@ export function createAnthropicResponseBuilder(options) {
|
|
|
111
111
|
break;
|
|
112
112
|
}
|
|
113
113
|
case 'message_delta': {
|
|
114
|
-
const
|
|
114
|
+
const data = event.data ?? {};
|
|
115
|
+
const delta = data?.delta;
|
|
115
116
|
if (delta?.stop_reason) {
|
|
116
117
|
state.stopReason = delta.stop_reason;
|
|
117
118
|
}
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
// 部分实现将 usage 挂在 delta.usage,部分实现挂在顶层 event.data.usage,
|
|
120
|
+
// 这里统一优先读取 delta.usage,缺失时回退到 data.usage。
|
|
121
|
+
const usageNode = (delta && delta.usage) ?? data.usage;
|
|
122
|
+
if (usageNode) {
|
|
123
|
+
state.usage = usageNode;
|
|
120
124
|
}
|
|
121
125
|
break;
|
|
122
126
|
}
|
|
@@ -132,6 +136,22 @@ export function createAnthropicResponseBuilder(options) {
|
|
|
132
136
|
},
|
|
133
137
|
getResult() {
|
|
134
138
|
if (!state.completed) {
|
|
139
|
+
// 对部分实现(或网络提前关闭)导致缺失 message_stop 的 SSE 流,
|
|
140
|
+
// 只要已经累计到可用内容,就以最佳努力方式返回结果,而不是直接抛错。
|
|
141
|
+
if (state.content.length > 0) {
|
|
142
|
+
return {
|
|
143
|
+
success: true,
|
|
144
|
+
response: {
|
|
145
|
+
id: state.id || `msg_${Date.now()}`,
|
|
146
|
+
type: 'message',
|
|
147
|
+
role: state.role || 'assistant',
|
|
148
|
+
model: state.model || 'unknown',
|
|
149
|
+
content: state.content,
|
|
150
|
+
usage: state.usage,
|
|
151
|
+
stop_reason: state.stopReason ?? 'end_turn'
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
135
155
|
return { success: false, error: new Error('Anthropic SSE stream incomplete') };
|
|
136
156
|
}
|
|
137
157
|
return {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type StructuredApplyPatchKind = 'insert_after' | 'insert_before' | 'replace' | 'delete' | 'create_file' | 'delete_file';
|
|
2
|
+
export interface StructuredApplyPatchChange {
|
|
3
|
+
file?: string;
|
|
4
|
+
kind: StructuredApplyPatchKind | string;
|
|
5
|
+
anchor?: string;
|
|
6
|
+
target?: string;
|
|
7
|
+
lines?: string[] | string;
|
|
8
|
+
use_anchor_indent?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface StructuredApplyPatchPayload extends Record<string, unknown> {
|
|
11
|
+
instructions?: string;
|
|
12
|
+
file?: string;
|
|
13
|
+
changes: StructuredApplyPatchChange[];
|
|
14
|
+
}
|
|
15
|
+
export declare class StructuredApplyPatchError extends Error {
|
|
16
|
+
reason: string;
|
|
17
|
+
constructor(reason: string, message: string);
|
|
18
|
+
}
|
|
19
|
+
export declare function buildStructuredPatch(payload: StructuredApplyPatchPayload): string;
|
|
20
|
+
export declare function isStructuredApplyPatchPayload(candidate: unknown): candidate is StructuredApplyPatchPayload;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
export class StructuredApplyPatchError extends Error {
|
|
2
|
+
reason;
|
|
3
|
+
constructor(reason, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.reason = reason;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const SUPPORTED_KINDS = [
|
|
9
|
+
'insert_after',
|
|
10
|
+
'insert_before',
|
|
11
|
+
'replace',
|
|
12
|
+
'delete',
|
|
13
|
+
'create_file',
|
|
14
|
+
'delete_file'
|
|
15
|
+
];
|
|
16
|
+
const FILE_PATH_INVALID_RE = /[\r\n]/;
|
|
17
|
+
const INVALID_FILE_FORMAT_RE = /^([A-Za-z]:|\/)/;
|
|
18
|
+
const toSafeString = (value, label) => {
|
|
19
|
+
const str = typeof value === 'string' ? value : '';
|
|
20
|
+
if (!str.trim()) {
|
|
21
|
+
throw new StructuredApplyPatchError('missing_field', `${label} is required`);
|
|
22
|
+
}
|
|
23
|
+
return str;
|
|
24
|
+
};
|
|
25
|
+
const normalizeFilePath = (raw, label) => {
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
throw new StructuredApplyPatchError('invalid_file', `${label} must not be empty`);
|
|
29
|
+
}
|
|
30
|
+
if (FILE_PATH_INVALID_RE.test(trimmed)) {
|
|
31
|
+
throw new StructuredApplyPatchError('invalid_file', `${label} must be a single-line relative path`);
|
|
32
|
+
}
|
|
33
|
+
if (INVALID_FILE_FORMAT_RE.test(trimmed)) {
|
|
34
|
+
throw new StructuredApplyPatchError('invalid_file', `${label} must be relative to the workspace root`);
|
|
35
|
+
}
|
|
36
|
+
return trimmed.replace(/\\/g, '/');
|
|
37
|
+
};
|
|
38
|
+
const splitTextIntoLines = (input) => {
|
|
39
|
+
const normalized = input.replace(/\r/g, '');
|
|
40
|
+
const parts = normalized.split('\n');
|
|
41
|
+
if (parts.length && parts[parts.length - 1] === '') {
|
|
42
|
+
parts.pop();
|
|
43
|
+
}
|
|
44
|
+
return parts.length ? parts : [''];
|
|
45
|
+
};
|
|
46
|
+
const normalizeLines = (value, label) => {
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
if (!value.length) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
return value.map((entry, idx) => {
|
|
52
|
+
if (typeof entry !== 'string') {
|
|
53
|
+
if (entry === null || entry === undefined) {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
return String(entry);
|
|
57
|
+
}
|
|
58
|
+
// Preserve intentional whitespace
|
|
59
|
+
return entry.replace(/\r/g, '');
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (typeof value === 'string') {
|
|
63
|
+
return splitTextIntoLines(value);
|
|
64
|
+
}
|
|
65
|
+
if (value === null || value === undefined) {
|
|
66
|
+
throw new StructuredApplyPatchError('invalid_lines', `${label} must be an array of strings or a multi-line string`);
|
|
67
|
+
}
|
|
68
|
+
return [String(value)];
|
|
69
|
+
};
|
|
70
|
+
const buildContextLines = (raw) => splitTextIntoLines(raw).map((line) => ` ${line}`);
|
|
71
|
+
const buildPrefixedLines = (lines, prefix) => lines.map((line) => `${prefix}${line}`);
|
|
72
|
+
const detectIndentFromAnchor = (anchorLines, mode) => {
|
|
73
|
+
const source = mode === 'first' ? anchorLines[0] ?? '' : anchorLines[anchorLines.length - 1] ?? '';
|
|
74
|
+
const match = source.match(/^(\s*)/);
|
|
75
|
+
return match ? match[1] ?? '' : '';
|
|
76
|
+
};
|
|
77
|
+
const applyAnchorIndent = (lines, anchorLines, position, enabled) => {
|
|
78
|
+
if (!enabled) {
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
const indent = detectIndentFromAnchor(anchorLines, position);
|
|
82
|
+
if (!indent) {
|
|
83
|
+
return lines;
|
|
84
|
+
}
|
|
85
|
+
return lines.map((line) => {
|
|
86
|
+
if (!line.trim()) {
|
|
87
|
+
return line;
|
|
88
|
+
}
|
|
89
|
+
if (/^\s/.test(line)) {
|
|
90
|
+
return line;
|
|
91
|
+
}
|
|
92
|
+
return `${indent}${line}`;
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
export function buildStructuredPatch(payload) {
|
|
96
|
+
if (!payload || typeof payload !== 'object') {
|
|
97
|
+
throw new StructuredApplyPatchError('missing_payload', 'apply_patch arguments must be a JSON object');
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(payload.changes) || payload.changes.length === 0) {
|
|
100
|
+
throw new StructuredApplyPatchError('missing_changes', 'apply_patch requires a non-empty "changes" array');
|
|
101
|
+
}
|
|
102
|
+
const topLevelFile = typeof payload.file === 'string' && payload.file.trim()
|
|
103
|
+
? normalizeFilePath(payload.file, 'file')
|
|
104
|
+
: undefined;
|
|
105
|
+
const sectionOrder = [];
|
|
106
|
+
const fileSections = new Map();
|
|
107
|
+
const ensureUpdateSection = (file) => {
|
|
108
|
+
const existing = fileSections.get(file);
|
|
109
|
+
if (existing) {
|
|
110
|
+
if (existing.type !== 'update') {
|
|
111
|
+
throw new StructuredApplyPatchError('invalid_change_sequence', `File "${file}" already marked as ${existing.type}`);
|
|
112
|
+
}
|
|
113
|
+
return existing;
|
|
114
|
+
}
|
|
115
|
+
const created = { type: 'update', hunks: [] };
|
|
116
|
+
sectionOrder.push(file);
|
|
117
|
+
fileSections.set(file, created);
|
|
118
|
+
return created;
|
|
119
|
+
};
|
|
120
|
+
for (const [index, change] of payload.changes.entries()) {
|
|
121
|
+
if (!change || typeof change !== 'object') {
|
|
122
|
+
throw new StructuredApplyPatchError('invalid_change', `Change at index ${index} must be an object`);
|
|
123
|
+
}
|
|
124
|
+
const kindRaw = typeof change.kind === 'string' ? change.kind.trim().toLowerCase() : '';
|
|
125
|
+
if (!kindRaw) {
|
|
126
|
+
throw new StructuredApplyPatchError('invalid_change_kind', `Change at index ${index} is missing "kind"`);
|
|
127
|
+
}
|
|
128
|
+
if (!SUPPORTED_KINDS.includes(kindRaw)) {
|
|
129
|
+
throw new StructuredApplyPatchError('invalid_change_kind', `Unsupported change kind "${change.kind}" at index ${index}`);
|
|
130
|
+
}
|
|
131
|
+
const file = change.file ? normalizeFilePath(change.file, `changes[${index}].file`) : topLevelFile;
|
|
132
|
+
if (!file) {
|
|
133
|
+
throw new StructuredApplyPatchError('invalid_file', `Change at index ${index} is missing "file"`);
|
|
134
|
+
}
|
|
135
|
+
if (kindRaw === 'create_file') {
|
|
136
|
+
if (fileSections.has(file)) {
|
|
137
|
+
throw new StructuredApplyPatchError('invalid_change_sequence', `File "${file}" already has pending changes`);
|
|
138
|
+
}
|
|
139
|
+
const lines = normalizeLines(change.lines, `changes[${index}].lines`);
|
|
140
|
+
sectionOrder.push(file);
|
|
141
|
+
fileSections.set(file, { type: 'add', lines });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (kindRaw === 'delete_file') {
|
|
145
|
+
if (fileSections.has(file)) {
|
|
146
|
+
throw new StructuredApplyPatchError('invalid_change_sequence', `File "${file}" already has pending changes`);
|
|
147
|
+
}
|
|
148
|
+
sectionOrder.push(file);
|
|
149
|
+
fileSections.set(file, { type: 'delete' });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const section = ensureUpdateSection(file);
|
|
153
|
+
switch (kindRaw) {
|
|
154
|
+
case 'insert_after': {
|
|
155
|
+
const anchor = toSafeString(change.anchor, `changes[${index}].anchor`);
|
|
156
|
+
const anchorLines = splitTextIntoLines(anchor);
|
|
157
|
+
const additions = normalizeLines(change.lines, `changes[${index}].lines`);
|
|
158
|
+
if (!additions.length) {
|
|
159
|
+
throw new StructuredApplyPatchError('invalid_lines', `changes[${index}].lines must include at least one line`);
|
|
160
|
+
}
|
|
161
|
+
const prepared = applyAnchorIndent(additions, anchorLines, 'last', change.use_anchor_indent);
|
|
162
|
+
const hunkBody = [...buildContextLines(anchor), ...buildPrefixedLines(prepared, '+')];
|
|
163
|
+
section.hunks.push(hunkBody);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'insert_before': {
|
|
167
|
+
const anchor = toSafeString(change.anchor, `changes[${index}].anchor`);
|
|
168
|
+
const anchorLines = splitTextIntoLines(anchor);
|
|
169
|
+
const additions = normalizeLines(change.lines, `changes[${index}].lines`);
|
|
170
|
+
if (!additions.length) {
|
|
171
|
+
throw new StructuredApplyPatchError('invalid_lines', `changes[${index}].lines must include at least one line`);
|
|
172
|
+
}
|
|
173
|
+
const prepared = applyAnchorIndent(additions, anchorLines, 'first', change.use_anchor_indent);
|
|
174
|
+
const hunkBody = [...buildPrefixedLines(prepared, '+'), ...buildContextLines(anchor)];
|
|
175
|
+
section.hunks.push(hunkBody);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'replace': {
|
|
179
|
+
const target = toSafeString(change.target, `changes[${index}].target`);
|
|
180
|
+
const replacements = normalizeLines(change.lines, `changes[${index}].lines`);
|
|
181
|
+
const hunkBody = [
|
|
182
|
+
...buildPrefixedLines(splitTextIntoLines(target), '-'),
|
|
183
|
+
...buildPrefixedLines(replacements, '+')
|
|
184
|
+
];
|
|
185
|
+
section.hunks.push(hunkBody);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 'delete': {
|
|
189
|
+
const target = toSafeString(change.target, `changes[${index}].target`);
|
|
190
|
+
const hunkBody = buildPrefixedLines(splitTextIntoLines(target), '-');
|
|
191
|
+
section.hunks.push(hunkBody);
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
default: {
|
|
195
|
+
throw new StructuredApplyPatchError('invalid_change_kind', `Unsupported change kind "${change.kind}" at index ${index}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!sectionOrder.length) {
|
|
200
|
+
throw new StructuredApplyPatchError('missing_changes', 'apply_patch payload produced no file operations');
|
|
201
|
+
}
|
|
202
|
+
const lines = ['*** Begin Patch'];
|
|
203
|
+
for (const file of sectionOrder) {
|
|
204
|
+
const section = fileSections.get(file);
|
|
205
|
+
if (!section)
|
|
206
|
+
continue;
|
|
207
|
+
if (section.type === 'add') {
|
|
208
|
+
lines.push(`*** Add File: ${file}`);
|
|
209
|
+
for (const line of section.lines) {
|
|
210
|
+
lines.push(`+${line}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else if (section.type === 'delete') {
|
|
214
|
+
lines.push(`*** Delete File: ${file}`);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
lines.push(`*** Update File: ${file}`);
|
|
218
|
+
for (const hunk of section.hunks) {
|
|
219
|
+
// 每个 hunk 仅需一个开头的 "@@" 行,后面直接跟上下文/增删行。
|
|
220
|
+
for (const entry of hunk) {
|
|
221
|
+
if (!entry.startsWith('@@')) {
|
|
222
|
+
lines.push(entry);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
lines.push('*** End Patch');
|
|
229
|
+
return lines.join('\n');
|
|
230
|
+
}
|
|
231
|
+
export function isStructuredApplyPatchPayload(candidate) {
|
|
232
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const record = candidate;
|
|
236
|
+
if (!Array.isArray(record.changes)) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function normalizeToolName(value: unknown): string;
|
|
2
|
+
export declare function isShellToolName(value: unknown): boolean;
|
|
3
|
+
export declare function hasApplyPatchToolDeclared(tools: unknown[] | undefined): boolean;
|
|
4
|
+
export declare function buildShellDescription(toolDisplayName: string, hasApplyPatch: boolean): string;
|
|
5
|
+
export declare function appendApplyPatchReminder(description: string, hasApplyPatch: boolean): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const SHELL_TOOL_ALIASES = new Set(['shell', 'shell_command', 'exec_command']);
|
|
2
|
+
const APPLY_PATCH_NAME = 'apply_patch';
|
|
3
|
+
export function normalizeToolName(value) {
|
|
4
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
5
|
+
}
|
|
6
|
+
function extractToolFunctionName(entry) {
|
|
7
|
+
if (!entry || typeof entry !== 'object') {
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
const fnName = typeof entry.function?.name === 'string'
|
|
11
|
+
? entry.function.name
|
|
12
|
+
: undefined;
|
|
13
|
+
if (fnName && fnName.trim().length > 0) {
|
|
14
|
+
return fnName.trim();
|
|
15
|
+
}
|
|
16
|
+
const topName = typeof entry.name === 'string' ? entry.name : '';
|
|
17
|
+
return topName.trim();
|
|
18
|
+
}
|
|
19
|
+
export function isShellToolName(value) {
|
|
20
|
+
return SHELL_TOOL_ALIASES.has(normalizeToolName(value));
|
|
21
|
+
}
|
|
22
|
+
export function hasApplyPatchToolDeclared(tools) {
|
|
23
|
+
if (!Array.isArray(tools)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return tools.some((entry) => normalizeToolName(extractToolFunctionName(entry)) === APPLY_PATCH_NAME);
|
|
27
|
+
}
|
|
28
|
+
export function buildShellDescription(toolDisplayName, hasApplyPatch) {
|
|
29
|
+
const label = toolDisplayName && toolDisplayName.trim().length > 0
|
|
30
|
+
? toolDisplayName.trim()
|
|
31
|
+
: 'shell';
|
|
32
|
+
const base = 'Runs a shell command and returns its output.';
|
|
33
|
+
const workdirLine = `- Always set the \`workdir\` param when using the ${label} function. Avoid using \`cd\` unless absolutely necessary.`;
|
|
34
|
+
const applyPatchLine = '- Prefer apply_patch for editing files instead of shell redirection or here-doc usage.';
|
|
35
|
+
return hasApplyPatch ? `${base}\n${workdirLine}\n${applyPatchLine}` : `${base}\n${workdirLine}`;
|
|
36
|
+
}
|
|
37
|
+
export function appendApplyPatchReminder(description, hasApplyPatch) {
|
|
38
|
+
if (!hasApplyPatch) {
|
|
39
|
+
return description;
|
|
40
|
+
}
|
|
41
|
+
const trimmed = description?.trim() ?? '';
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
return buildShellDescription('shell', true);
|
|
44
|
+
}
|
|
45
|
+
if (trimmed.includes('apply_patch')) {
|
|
46
|
+
return trimmed;
|
|
47
|
+
}
|
|
48
|
+
const applyPatchLine = '- Prefer apply_patch for editing files instead of shell redirection or here-doc usage.';
|
|
49
|
+
return `${trimmed}\n${applyPatchLine}`;
|
|
50
|
+
}
|