@cspeach/cli 1.1.16 → 1.1.18
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 +9 -1
- package/dist/agent/repair-partial.js +51 -0
- package/dist/agent/tool-dispatch.js +22 -1
- package/dist/agent/turn-error-ux.js +8 -0
- package/dist/repl.js +13 -5
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/skills/signing-public-key.js +1 -1
- package/package.json +2 -2
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
|
+
}
|
|
@@ -31,7 +31,12 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
31
31
|
// GUIDE.md) — local scratch, not SAP objects. Rule 8 protects SAP batches;
|
|
32
32
|
// applying it here made the 2nd+ file (GUIDE.md) hit the batch card and,
|
|
33
33
|
// headless, get refused. Exempt from the gate AND from the batch count.
|
|
34
|
-
|
|
34
|
+
// Local project work (owner decision 2026-09-25, CAP spike F2): the same
|
|
35
|
+
// reasoning for ALL local work — file edits (the filesystem tools refuse any
|
|
36
|
+
// path outside the project) and the safelisted local build commands. One-shot
|
|
37
|
+
// refused the 2nd edit of a CAP schema and left it half-changed. SAP writes
|
|
38
|
+
// are gated exactly as before.
|
|
39
|
+
const scratchWrite = isLocalProjectWork(tool, args) || isScratchOutputWrite(tool, args, ctx.cwd);
|
|
35
40
|
if (tool.isMutating && !scratchWrite && shouldGateRule8()) {
|
|
36
41
|
const r = await presentSafetyConfirmation({
|
|
37
42
|
rule: 'RULE_8_BATCH_PLAN',
|
|
@@ -83,6 +88,22 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
83
88
|
return { content: JSON.stringify({ error: String(err) }), is_error: true };
|
|
84
89
|
}
|
|
85
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* True for local project work: the filesystem tools (confined to the project
|
|
93
|
+
* folder by resolveSafePath) and shell_exec (a hardcoded safelist of local build
|
|
94
|
+
* and VCS commands run in the project). Neither touches an SAP object, which is
|
|
95
|
+
* what Rule 8 protects — EXCEPT a deploy or publish: `npm run deploy` /
|
|
96
|
+
* `ui5 deploy` / `fiori deploy` upload to the ABAP repository, so any shell
|
|
97
|
+
* command with a deploy/publish argument still counts (deliberately broad).
|
|
98
|
+
*/
|
|
99
|
+
export function isLocalProjectWork(tool, args) {
|
|
100
|
+
if (tool.category === 'filesystem')
|
|
101
|
+
return true;
|
|
102
|
+
if (tool.category !== 'shell')
|
|
103
|
+
return false;
|
|
104
|
+
const argv = [args?.command, ...(Array.isArray(args?.args) ? args.args : [])].map((a) => String(a ?? ''));
|
|
105
|
+
return !argv.some((a) => /deploy|publish/i.test(a));
|
|
106
|
+
}
|
|
86
107
|
/**
|
|
87
108
|
* True for a mutating filesystem tool whose target sits under the project's
|
|
88
109
|
* own ./cspeach-out/ (cwd). Accepts `cspeach-out/…`, `./cspeach-out/…`, the
|
|
@@ -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-25T16:38:47.082Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-atc-fix": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// GENERATED FILE — do not edit by hand.
|
|
2
2
|
// Build artifact: ed25519 public key for SKILL.md body verification.
|
|
3
3
|
// Regenerated by `pnpm prebuild` on each release.
|
|
4
|
-
export const MANIFEST_PUBLIC_KEYS = ['a7Qj8ibfLTj49kQRTn1DOE/UAExuoBgjhaXxf33+JPY='];
|
|
4
|
+
export const MANIFEST_PUBLIC_KEYS = ['Nexqg3TSUYzVPirLv6h1tbCmpQF7MbAtVDmqoLAie+8=', 'a7Qj8ibfLTj49kQRTn1DOE/UAExuoBgjhaXxf33+JPY='];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.18",
|
|
4
4
|
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@anthropic-ai/sdk": "^0.90.0",
|
|
54
54
|
"@cspeach/register-core": "0.1.0",
|
|
55
|
-
"@cspeach/sap-client": "0.1.
|
|
55
|
+
"@cspeach/sap-client": "0.1.13",
|
|
56
56
|
"@iarna/toml": "^2.2.5",
|
|
57
57
|
"@inkjs/ui": "^2.0.0",
|
|
58
58
|
"@inquirer/prompts": "^7.0.0",
|