@cspeach/cli 1.1.16 → 1.1.17
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/agent/loop.js
CHANGED
|
@@ -37,7 +37,7 @@ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
|
|
|
37
37
|
import { renderStandardsBlock } from '../standards/standards-file.js';
|
|
38
38
|
import { composeSessionContextBlock } from './session-context.js';
|
|
39
39
|
import { objectKeyFromInput } from './retry-key.js';
|
|
40
|
-
import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
|
|
40
|
+
import { repairPartialBlocks, healSessionMessagesInPlace, healOrphanToolUses, isPartialJsonApiError } from './repair-partial.js';
|
|
41
41
|
import { drainSteering } from './steering-queue.js';
|
|
42
42
|
import { TurnStreamWriter } from './turn-stream.js';
|
|
43
43
|
import { applyToolResultCheckpoint } from './skill-checkpoint.js';
|
|
@@ -565,6 +565,14 @@ ${imageExpansion.error}`));
|
|
|
565
565
|
// the single chokepoint every request passes through, retries included —
|
|
566
566
|
// makes the 400 categorically impossible. Idempotent and O(messages).
|
|
567
567
|
healSessionMessagesInPlace(session.messages);
|
|
568
|
+
// 2026-09-24 — same chokepoint: a tool call cut off before its
|
|
569
|
+
// result was recorded 400s every later request (a customer's
|
|
570
|
+
// `continue` could never succeed). Give each orphan an error result
|
|
571
|
+
// so the model sees the call did not complete and redoes it.
|
|
572
|
+
const healedOrphans = healOrphanToolUses(session.messages);
|
|
573
|
+
if (healedOrphans > 0) {
|
|
574
|
+
emit(chalk.dim(`(repaired ${healedOrphans} tool call(s) cut off by an interruption — the model will redo them)`));
|
|
575
|
+
}
|
|
568
576
|
const streamParams = {
|
|
569
577
|
// A4 — per-turn override (plan model tiering) wins over the
|
|
570
578
|
// configured default; absent on every non-plan turn. model-governance
|
|
@@ -116,3 +116,54 @@ export function isPartialJsonApiError(err) {
|
|
|
116
116
|
&& s.includes('partial_json')
|
|
117
117
|
&& /not permitted|Extra inputs/i.test(s));
|
|
118
118
|
}
|
|
119
|
+
/** Result text for a tool call whose real result was lost to an interruption. */
|
|
120
|
+
export const INTERRUPTED_TOOL_RESULT = 'This tool call did not complete — the turn was interrupted before its result was recorded. ' +
|
|
121
|
+
'Its effect is unknown: check before relying on it, and re-run it if needed.';
|
|
122
|
+
/**
|
|
123
|
+
* 2026-09-24 — heal tool_use blocks that have no tool_result after them.
|
|
124
|
+
*
|
|
125
|
+
* A turn cut after the model streamed a tool_use but before the result was
|
|
126
|
+
* recorded leaves a history the API rejects on EVERY later request
|
|
127
|
+
* ("messages.N: `tool_use` ids were found without `tool_result` blocks
|
|
128
|
+
* immediately after") — a customer's `continue` could never succeed. Each
|
|
129
|
+
* orphan gets a synthetic error result, placed where the API requires it: at
|
|
130
|
+
* the front of the next user message (created if the next message is not a
|
|
131
|
+
* user message). Mutates `messages`; returns the number of results added.
|
|
132
|
+
* Idempotent; a no-op on a healthy conversation.
|
|
133
|
+
*/
|
|
134
|
+
export function healOrphanToolUses(messages) {
|
|
135
|
+
if (!messages)
|
|
136
|
+
return 0;
|
|
137
|
+
let added = 0;
|
|
138
|
+
for (let i = 0; i < messages.length; i++) {
|
|
139
|
+
const msg = messages[i];
|
|
140
|
+
if (msg?.role !== 'assistant' || !Array.isArray(msg.content))
|
|
141
|
+
continue;
|
|
142
|
+
const ids = msg.content
|
|
143
|
+
.filter((b) => b?.type === 'tool_use' && typeof b.id === 'string')
|
|
144
|
+
.map((b) => b.id);
|
|
145
|
+
if (ids.length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
let next = messages[i + 1];
|
|
148
|
+
if (next?.role !== 'user') {
|
|
149
|
+
next = { role: 'user', content: [] };
|
|
150
|
+
messages.splice(i + 1, 0, next);
|
|
151
|
+
}
|
|
152
|
+
else if (!Array.isArray(next.content)) {
|
|
153
|
+
next.content = [{ type: 'text', text: String(next.content ?? '') }];
|
|
154
|
+
}
|
|
155
|
+
const have = new Set(next.content.filter((b) => b?.type === 'tool_result').map((b) => b.tool_use_id));
|
|
156
|
+
const missing = ids
|
|
157
|
+
.filter((id) => !have.has(id))
|
|
158
|
+
.map((id) => ({ type: 'tool_result', tool_use_id: id, is_error: true, content: INTERRUPTED_TOOL_RESULT }));
|
|
159
|
+
if (missing.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
// tool_result blocks must lead the message: existing results, then the
|
|
162
|
+
// synthetic ones, then everything else (e.g. the user's "continue").
|
|
163
|
+
const results = next.content.filter((b) => b?.type === 'tool_result');
|
|
164
|
+
const rest = next.content.filter((b) => b?.type !== 'tool_result');
|
|
165
|
+
next.content = [...results, ...missing, ...rest];
|
|
166
|
+
added += missing.length;
|
|
167
|
+
}
|
|
168
|
+
return added;
|
|
169
|
+
}
|
|
@@ -39,6 +39,11 @@ export function describeError(err) {
|
|
|
39
39
|
}
|
|
40
40
|
if (msg.includes('ECONNRESET'))
|
|
41
41
|
return 'network connection reset';
|
|
42
|
+
// 2026-09-24 — the other shapes a dropped connection takes (our service
|
|
43
|
+
// restarting, a network blip): say it plainly instead of the raw text.
|
|
44
|
+
if (/fetch failed|socket hang up|other side closed|UND_ERR_SOCKET|ECONNREFUSED/i.test(msg)) {
|
|
45
|
+
return 'connection to CSPeach dropped';
|
|
46
|
+
}
|
|
42
47
|
if (msg.includes('ETIMEDOUT'))
|
|
43
48
|
return 'network timeout';
|
|
44
49
|
if (msg.toLowerCase().includes('aborterror'))
|
|
@@ -111,6 +116,9 @@ export async function renderTurnError(input) {
|
|
|
111
116
|
const reason = describeError(input.err);
|
|
112
117
|
const logPath = await writeErrorLog(input.session.id, input.err);
|
|
113
118
|
print(chalk.red(`⚠ Turn interrupted: ${reason}`));
|
|
119
|
+
if (input.inSession) {
|
|
120
|
+
print(chalk.yellow(` Your work so far is saved. Type continue to pick up where it stopped.`));
|
|
121
|
+
}
|
|
114
122
|
print(chalk.gray(` Session: ${input.session.id}`));
|
|
115
123
|
if (input.streamFilePath) {
|
|
116
124
|
print(chalk.gray(` Stream: ${input.streamFilePath}`));
|
package/dist/repl.js
CHANGED
|
@@ -1380,11 +1380,18 @@ export async function runRepl(opts) {
|
|
|
1380
1380
|
});
|
|
1381
1381
|
}
|
|
1382
1382
|
catch (err) {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1383
|
+
// 2026-09-24 — print the turn-error block INTO the conversation (it
|
|
1384
|
+
// stays on screen) instead of the overlay box that vanished after 5 s
|
|
1385
|
+
// — a customer's dropped turn left no trace of what happened or what
|
|
1386
|
+
// to do. Same block classic mode prints; in-session it leads with
|
|
1387
|
+
// "type continue" (the partial turn is already saved).
|
|
1388
|
+
const { renderTurnError } = await import('./agent/turn-error-ux.js');
|
|
1389
|
+
await renderTurnError({
|
|
1390
|
+
err,
|
|
1391
|
+
session,
|
|
1392
|
+
userInterrupted: currentTurnAbort?.signal.aborted ?? false,
|
|
1393
|
+
inSession: true,
|
|
1394
|
+
print: (line) => chunkEmitter.emit('chunk', line + '\n'),
|
|
1388
1395
|
});
|
|
1389
1396
|
}
|
|
1390
1397
|
finally {
|
|
@@ -2942,6 +2949,7 @@ export async function runRepl(opts) {
|
|
|
2942
2949
|
err,
|
|
2943
2950
|
session,
|
|
2944
2951
|
userInterrupted: interruptedByUser,
|
|
2952
|
+
inSession: true,
|
|
2945
2953
|
print: (line) => console.error(line),
|
|
2946
2954
|
});
|
|
2947
2955
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-09-
|
|
3
|
+
// Generated: 2026-09-24T16:31:52.103Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-atc-fix": {
|