@cspeach/cli 1.1.49 → 1.1.50
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/cold-prune.js +1 -1
- package/dist/agent/conversation-prompt.js +52 -0
- package/dist/agent/loop.js +83 -2
- package/dist/agent/oversized-results.js +204 -0
- package/dist/agent/turn-error-ux.js +14 -0
- package/dist/commands/plan-continue-gate.js +11 -0
- package/dist/cost/cost-log.js +4 -0
- package/dist/cost/system-block.js +25 -0
- package/dist/models/server-config.js +13 -0
- package/dist/projects/workspace.js +6 -3
- package/dist/repl.js +16 -6
- package/dist/session/fresh-start.js +15 -0
- package/dist/tools/_filesystem-shared.js +7 -0
- package/dist/tools/cut-markers.js +47 -0
- package/dist/tools/filesystem/extract-document.js +50 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/read-document.js +20 -4
- package/dist/tools/tool-output-read.js +5 -1
- package/dist/tools/write-pipeline.js +5 -0
- package/package.json +121 -121
- package/dist/commands/project.js +0 -301
- package/dist/doctor/checks/zcspeach.js +0 -96
- package/dist/projects/email-template.js +0 -56
- package/dist/projects/merge-cca.js +0 -292
- package/dist/tools/local-files.js +0 -31
- package/dist/tools/project/cca_merge.js +0 -164
- package/dist/tools/subagent/schedule_create.js +0 -131
- package/dist/ui/error-emitter.js +0 -21
- package/dist/ui/header.js +0 -41
- package/dist/ui/ink-classifier-route.js +0 -19
- package/dist/ui/rich-error-box.js +0 -9
- package/dist/ui/sidebar.js +0 -12
- package/dist/ui/status-emitter.js +0 -19
- package/dist/ui/status-line.js +0 -62
- package/dist/ui/status-row.js +0 -38
- package/dist/ui/todo-panel.js +0 -85
- package/dist/ui/widgets/ask-form.js +0 -250
- package/dist/ui/widgets/ask-question-modal.js +0 -178
- package/fiori/zlas-inspection-lr/package.json +0 -26
- package/fiori/zlas-inspection-lr/ui5-local.yaml +0 -45
- package/fiori/zlas-inspection-lr/ui5.yaml +0 -41
- package/fiori/zlas-inspection-lr/webapp/Component.js +0 -12
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +0 -9
- package/fiori/zlas-inspection-lr/webapp/index.html +0 -35
- package/fiori/zlas-inspection-lr/webapp/manifest.json +0 -145
- package/live-check-2026-10-17-1343.txt +0 -41
package/dist/agent/cold-prune.js
CHANGED
|
@@ -35,7 +35,7 @@ function elidedMarker(chars) {
|
|
|
35
35
|
return `[result elided at cold start — ${chars} chars]`;
|
|
36
36
|
}
|
|
37
37
|
/** Characters a tool_result's content carries (text, else its JSON). */
|
|
38
|
-
function contentChars(content) {
|
|
38
|
+
export function contentChars(content) {
|
|
39
39
|
if (typeof content === 'string')
|
|
40
40
|
return content.length;
|
|
41
41
|
if (!Array.isArray(content))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation architecture (review 2026-10-21 §5 stage 2 item 1) — the one
|
|
3
|
+
* system prompt for a session in assistant.skills = inline mode, where
|
|
4
|
+
* playbooks load into the conversation as <skill> blocks instead of
|
|
5
|
+
* replacing the system prompt.
|
|
6
|
+
*
|
|
7
|
+
* DORMANT (stage 0): nothing imports this until stage 2 adds the inline
|
|
8
|
+
* setting. First draft — stage 2's eval set may change the words; move the
|
|
9
|
+
* pin with them.
|
|
10
|
+
*
|
|
11
|
+
* The proxy holds the same text as a literal
|
|
12
|
+
* (cspeach-proxy/src/skills/conversation-prompt.ts);
|
|
13
|
+
* src/agent/__tests__/conversation-prompt-pin.test.ts keeps them byte-equal
|
|
14
|
+
* and checks that no module imports either copy yet. Keep the literal free of
|
|
15
|
+
* backslashes, backticks and ${ so the raw literal is the runtime text.
|
|
16
|
+
*/
|
|
17
|
+
export const CONVERSATION_SYSTEM_PROMPT = `You are CSPeach, an AI coding agent for SAP development. You are the developer's senior colleague: years of ABAP, S/4HANA, FI/CO, MM, SD, RAP, CDS and Fiori, and the person the team asks when something doesn't add up.
|
|
18
|
+
|
|
19
|
+
How you talk
|
|
20
|
+
- Answer what was asked, at the size it was asked. A quick question gets a short answer; "walk me through it" gets a walk-through.
|
|
21
|
+
- Lead with the answer or what you found. Then why. Then what you'd do next, if anything.
|
|
22
|
+
- Plain words, short sentences. Use headings, tables or lists only when they make the answer easier to read.
|
|
23
|
+
- Have a view. If something is a bad idea, say so and say why. If you're not sure, say what you'd check.
|
|
24
|
+
- No flattery. Apologise only when you got something wrong.
|
|
25
|
+
|
|
26
|
+
How you work
|
|
27
|
+
- Look before you answer. When the question is about this developer's system or project, read the real thing with your tools instead of guessing. Say in a few words what you're checking.
|
|
28
|
+
- Ground what you say in what you read: object names, line numbers, field names, counts. Never invent objects, fields, results or tool output. If a tool fails or no SAP system is connected, say so plainly and carry on with what you can do.
|
|
29
|
+
- Batch independent reads in one response. When a result says it was saved to a file, read the slice you need with tool_output_read; do not re-run the command.
|
|
30
|
+
- Send more than two source reads, any where-used scan, ATC-finding triage or a package inventory to a reader: call read_agent with a precise brief and work from its report. Do not read those sources yourself when the turn will keep working afterwards. Send independent readers in one response so they run at the same time. Do not use a reader for one or two reads, a small edit, or to check your own work. Never more than three readers for one task unless the person asks.
|
|
31
|
+
|
|
32
|
+
Playbooks
|
|
33
|
+
- Without a loaded playbook you read and explain, and you never change SAP. When the conversation turns into a change, or the person asks for a deliverable a workflow produces, say briefly what you'd do and call hand_off with the right workflow and a clear request.
|
|
34
|
+
- When a playbook is loaded (a <skill> block below), it is in charge. Follow its steps, its plan-then-wait, and its one request_approval for all changes. The Safety and Ground rules below still hold when a playbook says otherwise.
|
|
35
|
+
- The most recently loaded playbook wins over any earlier one.
|
|
36
|
+
- Load another playbook when the person's request needs it: call hand_off, or dispatch_skill after the person has picked the command. Do not do another playbook's work yourself.
|
|
37
|
+
|
|
38
|
+
Safety
|
|
39
|
+
- Every change in SAP needs a valid approval_id from request_approval. You cannot bypass this. If you will change more than one object, put all the changes in one request_approval first.
|
|
40
|
+
- If a plan is rejected, ask what to change. Do not quietly revise and re-submit.
|
|
41
|
+
- Snapshots are taken before every write, and syntax and activation are checked after it. Do not ask the person to confirm snapshots. Do not activate when there are syntax errors.
|
|
42
|
+
- If ATC or syntax errors block activation, fix them or ask. Never suppress or hide them.
|
|
43
|
+
- To change logic inside existing methods without changing the class definition, use sap_update_method per method, not sap_set_source.
|
|
44
|
+
- All writes go into the active session transport. Do not ask for a transport number per change. Never use $TMP unless the person asks for local-only work.
|
|
45
|
+
- If a tool error blocks you and you don't know how to recover, say so clearly and ask how to proceed. Stop and ask before a fourth attempt at the same operation.
|
|
46
|
+
- For an unclear request, ask one question before acting.
|
|
47
|
+
- Before you delete or overwrite an object in a production namespace, confirm in text that the person means it, even when you have an approval_id.
|
|
48
|
+
|
|
49
|
+
Ground rules
|
|
50
|
+
- Follow the team's development standards when they are given in <development_standards>; they win over your own preferences.
|
|
51
|
+
- Use the SAP system and profile given in <session_context>; never assume a different system.
|
|
52
|
+
- Never ask for, print or store passwords or keys.`;
|
package/dist/agent/loop.js
CHANGED
|
@@ -13,6 +13,7 @@ import { buildToolList, buildSkillToolAddition, placeSystemMessagesInPlace, reso
|
|
|
13
13
|
import { settleSessionToolLoading } from './tool-loading-pin.js';
|
|
14
14
|
import { resolveSkillAlias } from '../skill-catalog.js';
|
|
15
15
|
import { retryWithBackoff } from './retry.js';
|
|
16
|
+
import { capToolResultContent, isPromptTooLongError, promptTooLongNotice, promptTooLongRetryLine, safeHistoryChars, trimOversizedToolResults } from './oversized-results.js';
|
|
16
17
|
import { renderChunk, initialBufferState, render } from '../renderer/pipeline.js';
|
|
17
18
|
import { filterMachineBlocks, flushMachineBlocks, initialMachineBlockState } from '../renderer/machine-blocks.js';
|
|
18
19
|
import { nextWritingActivity } from '../renderer/fence-state.js';
|
|
@@ -32,6 +33,7 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
|
32
33
|
import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
|
|
33
34
|
import { reportCallUsage } from '../usage/usage-reporter.js';
|
|
34
35
|
import { appendCostLine, buildEntry as buildCostEntry, perCallTokens, summariseInputTransformations } from '../cost/cost-log.js';
|
|
36
|
+
import { noteSystemBlock, systemBlockKey } from '../cost/system-block.js';
|
|
35
37
|
import { sessionMaxTokens, clampMaxTokens, sendsBlockBinding } from './provider-shape.js';
|
|
36
38
|
import { profileOf, sendsEffortFor } from './connection-profile.js';
|
|
37
39
|
import { computeSessionCost } from '../cost/session-cost.js';
|
|
@@ -654,6 +656,40 @@ export async function runTurn(params) {
|
|
|
654
656
|
else
|
|
655
657
|
console.log(line);
|
|
656
658
|
}
|
|
659
|
+
// 2026-10-22 customer bug — an oversized tool result stayed in the history
|
|
660
|
+
// and every later message got "prompt is too long". Remove the largest tool
|
|
661
|
+
// results (user and assistant text stay) and save. Owner decision
|
|
662
|
+
// 2026-10-22: when something was removed, the SAME request goes out again
|
|
663
|
+
// once, in this turn (the user message is already in the history — never
|
|
664
|
+
// appended twice). No retry when the person stopped the turn, when nothing
|
|
665
|
+
// could be removed, or when this turn already retried once.
|
|
666
|
+
let promptTooLongRetried = false;
|
|
667
|
+
async function trimForPromptTooLong(err) {
|
|
668
|
+
const trimmed = trimOversizedToolResults(session.messages, safeHistoryChars(session.messages, err));
|
|
669
|
+
if (trimmed.removed > 0)
|
|
670
|
+
delete session.last_context_tokens;
|
|
671
|
+
session.turnInterrupted = false;
|
|
672
|
+
session.turnInterruptedReason = undefined;
|
|
673
|
+
await saveSession(session);
|
|
674
|
+
return trimmed;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Trim the history; when something was removed and a retry is allowed, show
|
|
678
|
+
* the info line and return true (the caller runs the same request again).
|
|
679
|
+
* Otherwise show the card (unless `card: false`) and return false.
|
|
680
|
+
*/
|
|
681
|
+
async function recoverFromPromptTooLong(err, opts = {}) {
|
|
682
|
+
const mayRetry = !promptTooLongRetried && !params.signal?.aborted;
|
|
683
|
+
const trimmed = await trimForPromptTooLong(err);
|
|
684
|
+
if (mayRetry && trimmed.removed > 0) {
|
|
685
|
+
promptTooLongRetried = true;
|
|
686
|
+
emit('\n' + promptTooLongRetryLine(trimmed).trimEnd());
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
if (opts.card !== false)
|
|
690
|
+
emit('\n' + promptTooLongNotice(trimmed).trimEnd());
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
657
693
|
// Guided clean-core journey (Task 3) — the journey survives only its own
|
|
658
694
|
// queued step (or a plain reply inside it); any other turn ends the chain
|
|
659
695
|
// with one line. No journey → nothing happens here.
|
|
@@ -1409,6 +1445,14 @@ ${imageExpansion.error}`));
|
|
|
1409
1445
|
}).trimEnd());
|
|
1410
1446
|
return;
|
|
1411
1447
|
}
|
|
1448
|
+
// 2026-10-22 — the provider says the prompt is too long: remove the
|
|
1449
|
+
// largest tool results, save, and run the same request once more (or
|
|
1450
|
+
// show the card). The main turn only.
|
|
1451
|
+
if (!readerChild && !params.ctx.subagent && isPromptTooLongError(err)) {
|
|
1452
|
+
if (await recoverFromPromptTooLong(err))
|
|
1453
|
+
continue;
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1412
1456
|
// Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
|
|
1413
1457
|
const status = err?.status ?? err?.response?.status;
|
|
1414
1458
|
const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
|
|
@@ -1659,6 +1703,15 @@ ${imageExpansion.error}`));
|
|
|
1659
1703
|
if (callIndex === 0 && currentAssistantContent.length === 0 && isChatUnavailable(err)) {
|
|
1660
1704
|
withdrawChatTurn((err?.status ?? err?.response?.status));
|
|
1661
1705
|
}
|
|
1706
|
+
// 2026-10-22 — "prompt is too long" on the stream, before anything
|
|
1707
|
+
// streamed: the same recovery as at create time. A retry runs the same
|
|
1708
|
+
// request again right here (nothing of this round is persisted). No
|
|
1709
|
+
// retry: the catch only decides; the card and the final save (with
|
|
1710
|
+
// turnInterrupted cleared) come from the recovery after the persist block.
|
|
1711
|
+
if (!readerChild && !params.ctx.subagent && !params.signal?.aborted && currentAssistantContent.length === 0 && isPromptTooLongError(err)) {
|
|
1712
|
+
if (await recoverFromPromptTooLong(err, { card: false }))
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1662
1715
|
}
|
|
1663
1716
|
// Final flush: emit any pending partial line after the stream ends.
|
|
1664
1717
|
// Runs on both clean completion and mid-stream interruption.
|
|
@@ -1805,6 +1858,18 @@ ${imageExpansion.error}`));
|
|
|
1805
1858
|
const callTokens = perCallTokens(nowTokens, lastLoggedTokens);
|
|
1806
1859
|
lastLoggedTokens = nowTokens;
|
|
1807
1860
|
const turnDurationMs = Date.now() - turnStartedAt;
|
|
1861
|
+
// Task 0.2 (conversation Stage 0) — main calls only: the skill header
|
|
1862
|
+
// this call sent and whether its system block matched the session's
|
|
1863
|
+
// previous main call (same four inputs as the headers above).
|
|
1864
|
+
const mainLine = !readerChild;
|
|
1865
|
+
const systemBlock = mainLine
|
|
1866
|
+
? noteSystemBlock(session, systemBlockKey({
|
|
1867
|
+
skill: params.skill,
|
|
1868
|
+
delegateSkill: params.delegateSkill,
|
|
1869
|
+
style: getAssistantStyle(),
|
|
1870
|
+
level: getAssistantLevel(),
|
|
1871
|
+
}))
|
|
1872
|
+
: undefined;
|
|
1808
1873
|
const entry = buildCostEntry({
|
|
1809
1874
|
turn: turnNumber,
|
|
1810
1875
|
// A4 — when a per-turn override ran the stream on a different model
|
|
@@ -1831,6 +1896,8 @@ ${imageExpansion.error}`));
|
|
|
1831
1896
|
// Task 18 (F24) — running reader totals of this turn (absent until a
|
|
1832
1897
|
// reader has run).
|
|
1833
1898
|
reader: params.ctx.readerSpend,
|
|
1899
|
+
skill: mainLine ? params.skill : undefined,
|
|
1900
|
+
system_block: systemBlock,
|
|
1834
1901
|
});
|
|
1835
1902
|
readerCallsLogged = params.ctx.readerSpend?.calls ?? 0;
|
|
1836
1903
|
void appendCostLine(session.id, entry);
|
|
@@ -1870,13 +1937,25 @@ ${imageExpansion.error}`));
|
|
|
1870
1937
|
await turnStreamWriter.close(interruptedError !== null
|
|
1871
1938
|
? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
|
|
1872
1939
|
: undefined);
|
|
1940
|
+
// 2026-10-22 — "prompt is too long" can also arrive on the stream: the
|
|
1941
|
+
// same recovery as at create time, instead of a "type continue" that
|
|
1942
|
+
// could only fail again.
|
|
1943
|
+
const recoverTooLong = interruptedError !== null && !readerChild && !params.ctx.subagent
|
|
1944
|
+
&& !params.signal?.aborted && isPromptTooLongError(interruptedError);
|
|
1873
1945
|
// Guided clean-core journey (Task 3, fix round 1) — the queued step was
|
|
1874
1946
|
// interrupted: pause the journey so "carry on" resumes it on the first try.
|
|
1947
|
+
// On a recovery the card is the one next step: the journey is paused, but
|
|
1948
|
+
// its "Paused … carry on" line is not printed.
|
|
1875
1949
|
if (interruptedError !== null && !readerChild && !params.ctx.subagent) {
|
|
1876
1950
|
const journeyLine = pauseJourneyForInterruptedTurn(session);
|
|
1877
|
-
if (journeyLine && !isHeadless())
|
|
1951
|
+
if (journeyLine && !isHeadless() && !recoverTooLong)
|
|
1878
1952
|
emit(journeyLine);
|
|
1879
1953
|
}
|
|
1954
|
+
if (recoverTooLong) {
|
|
1955
|
+
promptTooLongRetried = true; // after the persist block: never a retry from here
|
|
1956
|
+
await recoverFromPromptTooLong(interruptedError);
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1880
1959
|
// Re-throw AFTER persistence so the outer REPL catch can render the
|
|
1881
1960
|
// graceful error UX with full session-id + log-path context.
|
|
1882
1961
|
if (interruptedError !== null)
|
|
@@ -2434,10 +2513,12 @@ ${imageExpansion.error}`));
|
|
|
2434
2513
|
}
|
|
2435
2514
|
// Normal path falls through: the existing `toolResults.push(...)` block
|
|
2436
2515
|
// immediately below handles both success and the not-yet-capped failure case.
|
|
2516
|
+
// 2026-10-22 — the general guard: a result over TOOL_RESULT_MAX_CHARS
|
|
2517
|
+
// is cut with a note; a result under it is the same value (byte-identical).
|
|
2437
2518
|
toolResults.push({
|
|
2438
2519
|
type: 'tool_result',
|
|
2439
2520
|
tool_use_id: block.id,
|
|
2440
|
-
content: result.content,
|
|
2521
|
+
content: capToolResultContent(result.content),
|
|
2441
2522
|
is_error: result.is_error,
|
|
2442
2523
|
});
|
|
2443
2524
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oversized tool results (2026-10-22 customer bug).
|
|
3
|
+
*
|
|
4
|
+
* A Word spec whose pictures came back as base64 gave one tool result of
|
|
5
|
+
* ~2.3M tokens. The provider answered "prompt is too long", and because the
|
|
6
|
+
* result stayed in session.messages every later message failed the same way.
|
|
7
|
+
*
|
|
8
|
+
* - capToolResultContent: the loop's general guard. Any tool result over
|
|
9
|
+
* TOOL_RESULT_MAX_CHARS is cut with a note; anything under it is returned
|
|
10
|
+
* as the same value, so those requests stay byte-identical.
|
|
11
|
+
* - isPromptTooLongError + trimOversizedToolResults + promptTooLongNotice:
|
|
12
|
+
* the recovery when the provider still says the prompt is too long. Only
|
|
13
|
+
* tool_result content changes (largest first); the block, its tool_use_id
|
|
14
|
+
* pairing and every user and assistant text stay. Thinking blocks after
|
|
15
|
+
* the first changed result are dropped, as the cold-start prune does: they
|
|
16
|
+
* are bound to the old prefix.
|
|
17
|
+
*/
|
|
18
|
+
import { contentChars } from './cold-prune.js';
|
|
19
|
+
import { stripThinking } from '../commands/compact.js';
|
|
20
|
+
import { formatAttention, formatFailure, formatInfo } from '../renderer/notice-shape.js';
|
|
21
|
+
/** Largest tool result text the loop sends (≈100–130k tokens). */
|
|
22
|
+
export const TOOL_RESULT_MAX_CHARS = 400_000;
|
|
23
|
+
/** A tool result smaller than this is never removed by the recovery. */
|
|
24
|
+
export const TRIM_MIN_CHARS = 10_000;
|
|
25
|
+
/** History budget when the provider's error names no token counts. */
|
|
26
|
+
export const DEFAULT_SAFE_HISTORY_CHARS = 600_000;
|
|
27
|
+
/** Head-room under the provider's named limit (char→token ratios vary). */
|
|
28
|
+
const BUDGET_MARGIN = 0.7;
|
|
29
|
+
function cutNote(more) {
|
|
30
|
+
return `\n… [result cut: ${more} more chars. It was too large to send in one piece. Read it in smaller parts, for example with offset and limit.]`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Cap a tool result's text at `max` chars. A string or text blocks under the
|
|
34
|
+
* cap come back as the same value (no copy); image and other blocks are left
|
|
35
|
+
* alone. Never mutates its input.
|
|
36
|
+
*/
|
|
37
|
+
export function capToolResultContent(content, max = TOOL_RESULT_MAX_CHARS) {
|
|
38
|
+
if (typeof content === 'string') {
|
|
39
|
+
if (content.length <= max)
|
|
40
|
+
return content;
|
|
41
|
+
return (content.slice(0, max) + cutNote(content.length - max));
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(content))
|
|
44
|
+
return content;
|
|
45
|
+
let left = max;
|
|
46
|
+
let changed = false;
|
|
47
|
+
const out = content.map((b) => {
|
|
48
|
+
const block = b;
|
|
49
|
+
if (block?.type !== 'text' || typeof block.text !== 'string')
|
|
50
|
+
return b;
|
|
51
|
+
if (block.text.length <= left) {
|
|
52
|
+
left -= block.text.length;
|
|
53
|
+
return b;
|
|
54
|
+
}
|
|
55
|
+
changed = true;
|
|
56
|
+
const keep = left;
|
|
57
|
+
left = 0;
|
|
58
|
+
return { ...block, text: block.text.slice(0, keep) + cutNote(block.text.length - keep) };
|
|
59
|
+
});
|
|
60
|
+
return (changed ? out : content);
|
|
61
|
+
}
|
|
62
|
+
// The API's "prompt is too long" 400 and its 413 request_too_large; the
|
|
63
|
+
// local / OpenAI-shaped context-length error.
|
|
64
|
+
const PROMPT_TOO_LONG_RE = /prompt is too long|request_too_large|context_length_exceeded|maximum context length/i;
|
|
65
|
+
function errorTexts(err) {
|
|
66
|
+
if (err == null)
|
|
67
|
+
return [];
|
|
68
|
+
const e = err;
|
|
69
|
+
const out = [];
|
|
70
|
+
out.push(err instanceof Error ? err.message : typeof e.message === 'string' ? e.message : String(err));
|
|
71
|
+
if (e.error != null) {
|
|
72
|
+
try {
|
|
73
|
+
out.push(typeof e.error === 'string' ? e.error : JSON.stringify(e.error));
|
|
74
|
+
}
|
|
75
|
+
catch { /* unserialisable body */ }
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* True for the provider's "prompt is too long" refusal — the same words come
|
|
81
|
+
* back from a direct provider and through our proxy (message text or the
|
|
82
|
+
* SDK's parsed `.error` body) — and for the same condition in its other
|
|
83
|
+
* shapes: the API's 413 request_too_large and the local / OpenAI-shaped
|
|
84
|
+
* context-length error.
|
|
85
|
+
*/
|
|
86
|
+
export function isPromptTooLongError(err) {
|
|
87
|
+
return errorTexts(err).some((t) => PROMPT_TOO_LONG_RE.test(t));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The history size (JSON chars) to trim down to. When the error names the
|
|
91
|
+
* counts ("2357619 tokens > 1000000 maximum") the history is scaled to that
|
|
92
|
+
* limit with head-room; otherwise DEFAULT_SAFE_HISTORY_CHARS.
|
|
93
|
+
*/
|
|
94
|
+
export function safeHistoryChars(messages, err) {
|
|
95
|
+
for (const t of errorTexts(err)) {
|
|
96
|
+
const m = /(\d+)\s*tokens?\s*>\s*(\d+)/i.exec(t);
|
|
97
|
+
if (!m)
|
|
98
|
+
continue;
|
|
99
|
+
const sent = Number(m[1]);
|
|
100
|
+
const limit = Number(m[2]);
|
|
101
|
+
if (sent > 0 && limit > 0 && limit < sent) {
|
|
102
|
+
return Math.floor(JSON.stringify(messages).length * (limit / sent) * BUDGET_MARGIN);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return DEFAULT_SAFE_HISTORY_CHARS;
|
|
106
|
+
}
|
|
107
|
+
function removedNote(chars) {
|
|
108
|
+
return `[removed: this result was too large to send — ${chars} chars. Read it in smaller parts.]`;
|
|
109
|
+
}
|
|
110
|
+
/** Recognises a result this recovery already removed. */
|
|
111
|
+
export const REMOVED_RESULT_RE = /^\[removed: this result was too large to send — \d+ chars\. Read it in smaller parts\.\]$/;
|
|
112
|
+
/**
|
|
113
|
+
* Replace the content of the largest tool results (each over TRIM_MIN_CHARS)
|
|
114
|
+
* with a short note until the history's JSON is under `budgetChars`. At least
|
|
115
|
+
* the largest one goes even when the count already looks under budget (the
|
|
116
|
+
* provider said it is too long). Mutates `messages` in place.
|
|
117
|
+
*/
|
|
118
|
+
export function trimOversizedToolResults(messages, budgetChars) {
|
|
119
|
+
const candidates = [];
|
|
120
|
+
messages.forEach((m, i) => {
|
|
121
|
+
if (m.role !== 'user' || !Array.isArray(m.content))
|
|
122
|
+
return;
|
|
123
|
+
for (const block of m.content) {
|
|
124
|
+
if (block?.type !== 'tool_result')
|
|
125
|
+
continue;
|
|
126
|
+
if (typeof block.content === 'string' && REMOVED_RESULT_RE.test(block.content))
|
|
127
|
+
continue;
|
|
128
|
+
const chars = contentChars(block.content);
|
|
129
|
+
if (chars >= TRIM_MIN_CHARS)
|
|
130
|
+
candidates.push({ msg: i, block, chars });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
if (candidates.length === 0)
|
|
134
|
+
return { removed: 0, removedChars: 0 };
|
|
135
|
+
candidates.sort((a, b) => b.chars - a.chars);
|
|
136
|
+
let total = JSON.stringify(messages).length;
|
|
137
|
+
let removed = 0;
|
|
138
|
+
let removedChars = 0;
|
|
139
|
+
let firstChanged = Infinity;
|
|
140
|
+
for (const c of candidates) {
|
|
141
|
+
if (removed > 0 && total <= budgetChars)
|
|
142
|
+
break;
|
|
143
|
+
const before = JSON.stringify(c.block.content ?? null).length;
|
|
144
|
+
const note = removedNote(c.chars);
|
|
145
|
+
c.block.content = note;
|
|
146
|
+
total -= before - JSON.stringify(note).length;
|
|
147
|
+
removed++;
|
|
148
|
+
removedChars += c.chars;
|
|
149
|
+
firstChanged = Math.min(firstChanged, c.msg);
|
|
150
|
+
}
|
|
151
|
+
// Round 2+ of a tool loop: the history ends in tool_results answering the
|
|
152
|
+
// last assistant message. That message keeps its thinking (the API wants
|
|
153
|
+
// the open tool loop's assistant turn to start with it); the cold-prune
|
|
154
|
+
// rule applies to the messages before it.
|
|
155
|
+
const openLoopAssistant = openToolLoopAssistant(messages);
|
|
156
|
+
for (let i = firstChanged + 1; i < messages.length; i++) {
|
|
157
|
+
if (i === openLoopAssistant)
|
|
158
|
+
continue;
|
|
159
|
+
messages[i] = stripThinking(messages[i]);
|
|
160
|
+
}
|
|
161
|
+
return { removed, removedChars };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Index of the assistant message whose tool_use the history's final
|
|
165
|
+
* tool_results answer, or -1 when the history does not end that way.
|
|
166
|
+
*/
|
|
167
|
+
function openToolLoopAssistant(messages) {
|
|
168
|
+
const last = messages[messages.length - 1];
|
|
169
|
+
const prev = messages[messages.length - 2];
|
|
170
|
+
if (!last || !prev || last.role !== 'user' || prev.role !== 'assistant')
|
|
171
|
+
return -1;
|
|
172
|
+
if (!Array.isArray(last.content) || !Array.isArray(prev.content))
|
|
173
|
+
return -1;
|
|
174
|
+
const answered = new Set(last.content
|
|
175
|
+
.filter((b) => b?.type === 'tool_result')
|
|
176
|
+
.map((b) => b.tool_use_id));
|
|
177
|
+
const asks = prev.content.some((b) => b?.type === 'tool_use' && answered.has(b.id));
|
|
178
|
+
return asks ? messages.length - 2 : -1;
|
|
179
|
+
}
|
|
180
|
+
/** The card shown after the recovery ran (never "type continue"). */
|
|
181
|
+
export function promptTooLongNotice(r) {
|
|
182
|
+
if (r.removed === 0) {
|
|
183
|
+
return formatFailure({
|
|
184
|
+
what: 'This conversation is too large to send',
|
|
185
|
+
detail: ['There is nothing large left that we can remove from it.'],
|
|
186
|
+
next: 'type /new to start a fresh conversation',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const what = r.removed === 1 ? 'one large tool result' : `${r.removed} large tool results`;
|
|
190
|
+
return formatAttention({
|
|
191
|
+
text: 'This conversation got too large to send',
|
|
192
|
+
fact: `we removed ${what} (${r.removedChars.toLocaleString('en-US')} characters) and saved the session · your messages and our replies are kept`,
|
|
193
|
+
next: 'send your message again',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The one info line before the automatic retry (owner decision 2026-10-22):
|
|
198
|
+
* the history was trimmed and the same request goes out again, so there is
|
|
199
|
+
* no "Next:" step for the person.
|
|
200
|
+
*/
|
|
201
|
+
export function promptTooLongRetryLine(r) {
|
|
202
|
+
const what = r.removed === 1 ? 'one large tool result' : `${r.removed} large tool results`;
|
|
203
|
+
return formatInfo(`We removed ${what} (${r.removedChars.toLocaleString('en-US')} characters) to keep this conversation within its limit. Your messages and our replies are kept.`);
|
|
204
|
+
}
|
|
@@ -23,6 +23,7 @@ import * as path from 'node:path';
|
|
|
23
23
|
import { TurnStreamWriter } from './turn-stream.js';
|
|
24
24
|
import { azureSignInTurnLine } from '../connection/azure-turn-error.js';
|
|
25
25
|
import { azureErrorNotice, azureErrorContext } from '../connection/azure-errors.js';
|
|
26
|
+
import { isPromptTooLongError } from './oversized-results.js';
|
|
26
27
|
export function logsRoot() {
|
|
27
28
|
return path.join(os.homedir(), '.cspeach', 'logs');
|
|
28
29
|
}
|
|
@@ -226,6 +227,19 @@ export async function renderTurnError(input) {
|
|
|
226
227
|
print('');
|
|
227
228
|
return;
|
|
228
229
|
}
|
|
230
|
+
// 2026-10-22 — "prompt is too long": the loop recovers the main turn
|
|
231
|
+
// (agent/oversized-results.ts). Anything that still reaches here must not
|
|
232
|
+
// say "type continue" — that re-sends the same history and fails again.
|
|
233
|
+
if (isPromptTooLongError(input.err)) {
|
|
234
|
+
await writeErrorLog(id, input.err);
|
|
235
|
+
printBlock(print, formatFailure({
|
|
236
|
+
what: 'This conversation is too large to send',
|
|
237
|
+
detail: [describeError(input.err), `Session: ${id}`],
|
|
238
|
+
next: 'type /new to start a fresh conversation',
|
|
239
|
+
}));
|
|
240
|
+
print('');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
229
243
|
const reason = describeError(input.err);
|
|
230
244
|
const logPath = await writeErrorLog(id, input.err);
|
|
231
245
|
const detail = [reason, `Session: ${id}`];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marker a turn continues from, or null: only an /abap-plan turn that is
|
|
3
|
+
* not already a plan resume, with a plan step in flight. /new clears the
|
|
4
|
+
* marker (session/fresh-start.ts), so "continue" after /new never takes this
|
|
5
|
+
* path.
|
|
6
|
+
*/
|
|
7
|
+
export function planContinuationMarker(session, skill, planResume) {
|
|
8
|
+
if (skill !== 'abap-plan' || planResume !== null)
|
|
9
|
+
return null;
|
|
10
|
+
return session.plan_in_flight ?? null;
|
|
11
|
+
}
|
package/dist/cost/cost-log.js
CHANGED
|
@@ -83,6 +83,10 @@ export function buildEntry(opts) {
|
|
|
83
83
|
entry.reader_cost_usd = Math.round(opts.reader.cost * 10000) / 10000;
|
|
84
84
|
entry.reader_calls = opts.reader.calls;
|
|
85
85
|
}
|
|
86
|
+
if (opts.skill !== undefined)
|
|
87
|
+
entry.skill = opts.skill;
|
|
88
|
+
if (opts.system_block !== undefined)
|
|
89
|
+
entry.system_block = opts.system_block;
|
|
86
90
|
return entry;
|
|
87
91
|
}
|
|
88
92
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation Stage 0, Task 0.2 — did this main call send the same system
|
|
3
|
+
* block as the session's previous one?
|
|
4
|
+
*
|
|
5
|
+
* The system block is decided by four inputs, on the proxy (skill body,
|
|
6
|
+
* delegate skill, style text, level text; the preamble is constant per CLI
|
|
7
|
+
* version) and on direct providers alike. The key joins them; the note
|
|
8
|
+
* compares it with the session's last key. Observability only: the result is
|
|
9
|
+
* written to the cost log and never changes a request.
|
|
10
|
+
*
|
|
11
|
+
* Kept in a module WeakMap, never in the session file (no schema change). A
|
|
12
|
+
* resumed session therefore starts at 'first' again.
|
|
13
|
+
*/
|
|
14
|
+
export function systemBlockKey(p) {
|
|
15
|
+
return `${p.skill}|${p.delegateSkill ?? ''}|${p.style}|${p.level}`;
|
|
16
|
+
}
|
|
17
|
+
const lastKeyBySession = new WeakMap();
|
|
18
|
+
/** Records `key` as the session's latest and says how it compares with the previous one. */
|
|
19
|
+
export function noteSystemBlock(session, key) {
|
|
20
|
+
const prev = lastKeyBySession.get(session);
|
|
21
|
+
lastKeyBySession.set(session, key);
|
|
22
|
+
if (prev === undefined)
|
|
23
|
+
return 'first';
|
|
24
|
+
return prev === key ? 'same' : 'changed';
|
|
25
|
+
}
|
|
@@ -105,12 +105,14 @@ function coerceDocument(body, fetchedAt) {
|
|
|
105
105
|
return null;
|
|
106
106
|
const toolLoading = coerceToolLoading(b.tool_loading);
|
|
107
107
|
const autoCompactTokens = coerceAutoCompactTokens(b.auto_compact_tokens);
|
|
108
|
+
const inlineSkills = typeof b.inline_skills === 'boolean' ? b.inline_skills : undefined;
|
|
108
109
|
return {
|
|
109
110
|
roles,
|
|
110
111
|
pricing: coercePricing(b.pricing),
|
|
111
112
|
effort: coerceEffort(b.effort),
|
|
112
113
|
...(toolLoading ? { tool_loading: toolLoading } : {}),
|
|
113
114
|
...(autoCompactTokens !== undefined ? { auto_compact_tokens: autoCompactTokens } : {}),
|
|
115
|
+
...(inlineSkills !== undefined ? { inline_skills: inlineSkills } : {}),
|
|
114
116
|
fetchedAt: typeof fetchedAt === 'string' && fetchedAt.length > 0 ? fetchedAt : new Date().toISOString(),
|
|
115
117
|
};
|
|
116
118
|
}
|
|
@@ -277,6 +279,17 @@ export function servedToolLoading() {
|
|
|
277
279
|
export function managedProxyServesNewShapes() {
|
|
278
280
|
return snapshot?.tool_loading !== undefined;
|
|
279
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Conversation stage 0 (Task 0.5) — does the managed proxy serve the
|
|
284
|
+
* conversation prompt? True only when its served config says
|
|
285
|
+
* `inline_skills: true`; false with no snapshot or an older proxy. Managed
|
|
286
|
+
* snapshot only: BYOK builds its own system prompt and never needs it.
|
|
287
|
+
* Dormant — nothing calls it until stage 2 (pinned by
|
|
288
|
+
* __tests__/served-inline-skills-pin.test.ts). Pure sync read.
|
|
289
|
+
*/
|
|
290
|
+
export function servedInlineSkills() {
|
|
291
|
+
return snapshot?.inline_skills === true;
|
|
292
|
+
}
|
|
280
293
|
// 2026-10-02 (BYOK local routing) — BYOK now classifies prompts on the laptop
|
|
281
294
|
// (direct to the model, the customer's key) instead of POSTing them to our
|
|
282
295
|
// /v1/classify. It uses the router model CSPeach serves, `roles.router`, kept
|
|
@@ -606,6 +606,8 @@ function shortHint(filename) {
|
|
|
606
606
|
* char boundary because we slice the decoded string, not the bytes.
|
|
607
607
|
*/
|
|
608
608
|
const MAX_TEXT_FILE_CHARS = 64 * 1024;
|
|
609
|
+
/** The note after a cut attachment (also refused in SAP writes: tools/cut-markers.ts). */
|
|
610
|
+
export const ATTACHMENT_TRUNCATED_NOTE = '... [truncated, file exceeds 64 KB]';
|
|
609
611
|
/**
|
|
610
612
|
* Scan `userMessage` for `@<token>` references that resolve to .txt /
|
|
611
613
|
* .md files in the workspace, read their content, and substitute the
|
|
@@ -690,9 +692,10 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
|
|
|
690
692
|
try {
|
|
691
693
|
const buf = readFileSync(path);
|
|
692
694
|
const ext = path.toLowerCase().endsWith('.pdf') ? '.pdf' : '.docx';
|
|
693
|
-
const { extractDocument } = await import('../tools/filesystem/extract-document.js');
|
|
695
|
+
const { extractDocument, splitLongLines, DOC_MAX_LINE_CHARS } = await import('../tools/filesystem/extract-document.js');
|
|
694
696
|
const res = await extractDocument(buf, ext, { maxPages: 150 });
|
|
695
|
-
|
|
697
|
+
// 2026-10-22 — long lines split as in read_document; the 64 KB cap below is the total.
|
|
698
|
+
content = splitLongLines(res.text, DOC_MAX_LINE_CHARS);
|
|
696
699
|
if (log)
|
|
697
700
|
for (const w of res.warnings)
|
|
698
701
|
log(`(${filename}: ${w})`);
|
|
@@ -714,7 +717,7 @@ export async function expandTextFileAttachments(userMessage, cwd, log) {
|
|
|
714
717
|
}
|
|
715
718
|
}
|
|
716
719
|
if (content.length > MAX_TEXT_FILE_CHARS) {
|
|
717
|
-
content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n
|
|
720
|
+
content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n' + ATTACHMENT_TRUNCATED_NOTE;
|
|
718
721
|
}
|
|
719
722
|
const replacement = `<attached file="${filename}">\n${content}\n</attached>`;
|
|
720
723
|
substitutions.push({ index, matchLen: match.length, replacement });
|
package/dist/repl.js
CHANGED
|
@@ -51,6 +51,8 @@ import { getConnection } from './sap/connection-manager.js';
|
|
|
51
51
|
import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
|
|
52
52
|
import { AdtClient, snapshots } from '@cspeach/sap-client';
|
|
53
53
|
import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
|
|
54
|
+
import { startFreshConversation, freshStartText } from './session/fresh-start.js';
|
|
55
|
+
import { planContinuationMarker } from './commands/plan-continue-gate.js';
|
|
54
56
|
import { newSession } from './session/schema.js';
|
|
55
57
|
import { resolveModelRole } from './models/resolve.js';
|
|
56
58
|
import { familyOf, planTierModelFor, getActiveDeployments } from './models/model-identity.js';
|
|
@@ -1376,7 +1378,11 @@ export async function runRepl(opts) {
|
|
|
1376
1378
|
todoEmitter.emit('update', []);
|
|
1377
1379
|
clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
|
|
1378
1380
|
clearJourneyFlow(session); // Task 3 — and a clean-core journey
|
|
1379
|
-
|
|
1381
|
+
// 2026-10-22 — and the conversation history: the next prompt sends none of it.
|
|
1382
|
+
startFreshConversation(session);
|
|
1383
|
+
idleKeepalive.disarm(); // no idle cache ping for the old history
|
|
1384
|
+
await saveSession(session);
|
|
1385
|
+
chunkEmitter.emit('chunk', '\n' + formatInfo(freshStartText(laneSetting)));
|
|
1380
1386
|
return;
|
|
1381
1387
|
}
|
|
1382
1388
|
// /chat [on|off] (spec 2026-10-17 D6, owner amendment) — leave a workflow,
|
|
@@ -1925,8 +1931,8 @@ export async function runRepl(opts) {
|
|
|
1925
1931
|
// prompt is NOT replaced and the context is NOT reset — "continue"
|
|
1926
1932
|
// needs the accumulated transcript.
|
|
1927
1933
|
let planContinue = null;
|
|
1928
|
-
|
|
1929
|
-
|
|
1934
|
+
const marker = planContinuationMarker(session, skill, planResume);
|
|
1935
|
+
if (marker) {
|
|
1930
1936
|
const { preparePlanContinuation } = await import('./commands/plan-continue.js');
|
|
1931
1937
|
const r = await preparePlanContinuation({
|
|
1932
1938
|
marker,
|
|
@@ -3397,7 +3403,11 @@ export async function runRepl(opts) {
|
|
|
3397
3403
|
session.todos = []; // P2 — /new clears the plan (spec §4)
|
|
3398
3404
|
clearAppFlow(session); // Task 7 — /new ends an /abap-app flow
|
|
3399
3405
|
clearJourneyFlow(session); // Task 3 — and a clean-core journey
|
|
3400
|
-
|
|
3406
|
+
// 2026-10-22 — and the conversation history: the next prompt sends none of it.
|
|
3407
|
+
startFreshConversation(session);
|
|
3408
|
+
idleKeepalive.disarm(); // no idle cache ping for the old history
|
|
3409
|
+
await saveSession(session);
|
|
3410
|
+
console.log(formatInfo(freshStartText(laneSetting)).trimEnd());
|
|
3401
3411
|
continue;
|
|
3402
3412
|
}
|
|
3403
3413
|
// /chat [on|off] — classic mirror of the Ink handler above.
|
|
@@ -3990,8 +4000,8 @@ export async function runRepl(opts) {
|
|
|
3990
4000
|
// of the Ink branch; see there for the full design note: mutually
|
|
3991
4001
|
// exclusive with --resume, prompt NOT replaced, context NOT reset).
|
|
3992
4002
|
let planContinue = null;
|
|
3993
|
-
|
|
3994
|
-
|
|
4003
|
+
const marker = planContinuationMarker(session, skill, planResume);
|
|
4004
|
+
if (marker) {
|
|
3995
4005
|
const { preparePlanContinuation } = await import('./commands/plan-continue.js');
|
|
3996
4006
|
const r = await preparePlanContinuation({
|
|
3997
4007
|
marker,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function startFreshConversation(session) {
|
|
2
|
+
session.messages = [];
|
|
3
|
+
session.turnInterrupted = false;
|
|
4
|
+
session.turnInterruptedReason = undefined;
|
|
5
|
+
delete session.last_context_tokens;
|
|
6
|
+
delete session.lastAutoCompactTurn;
|
|
7
|
+
// An open plan step does not survive: "continue" must not resume it.
|
|
8
|
+
session.plan_in_flight = null;
|
|
9
|
+
}
|
|
10
|
+
/** The one line /new prints. */
|
|
11
|
+
export function freshStartText(lane) {
|
|
12
|
+
return lane === 'chat'
|
|
13
|
+
? 'Fresh start · the conversation is cleared.'
|
|
14
|
+
: 'Fresh start · the conversation is cleared · the next prompt picks a skill again';
|
|
15
|
+
}
|