@cspeach/cli 1.1.48 → 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/level.js +1 -1
- package/dist/agent/loop.js +90 -8
- package/dist/agent/oversized-results.js +204 -0
- package/dist/agent/sap-connection-adapter.js +2 -0
- package/dist/agent/system-prompt.js +2 -2
- package/dist/agent/tool-dispatch.js +13 -1
- package/dist/agent/turn-error-ux.js +14 -0
- package/dist/approvals/op-labels.js +49 -10
- package/dist/approvals/render.js +14 -23
- package/dist/approvals/risk-floor.js +196 -19
- package/dist/approvals/visible-changes.js +35 -0
- package/dist/commands/help.js +1 -1
- package/dist/commands/plan-continue-gate.js +11 -0
- package/dist/commands/team-plan.js +16 -6
- package/dist/commands/team.js +295 -68
- package/dist/cost/cost-log.js +4 -0
- package/dist/cost/system-block.js +25 -0
- package/dist/journey/chain.js +489 -61
- package/dist/journey/clean-core.js +61 -36
- package/dist/journey/flow.js +75 -2
- package/dist/journey/people.js +37 -0
- package/dist/journey/rebuild.js +7 -6
- package/dist/journey/run.js +129 -42
- package/dist/journey/trigger.js +16 -10
- package/dist/models/server-config.js +13 -0
- package/dist/one-shot.js +1 -1
- package/dist/project-context/render.js +7 -0
- package/dist/projects/clean-core-confirm.js +166 -44
- package/dist/projects/extract-cca.js +8 -7
- package/dist/projects/index.js +0 -1
- package/dist/projects/merge-upgrade.js +2 -2
- package/dist/projects/project-folder.js +2 -1
- package/dist/projects/save-command.js +51 -14
- package/dist/projects/workspace.js +6 -3
- package/dist/register/decision-rules.js +2 -1
- package/dist/register/export-files.js +18 -0
- package/dist/register/picture.js +3 -3
- package/dist/register/project-transport.js +125 -0
- package/dist/register/records.js +25 -13
- package/dist/register/rules-edit.js +5 -0
- package/dist/register/status-file/progress-chart.js +17 -6
- package/dist/register/status-file/register-map.js +8 -5
- package/dist/register/status-file/register-model.js +55 -36
- package/dist/register/status-file/register-parts.js +97 -82
- package/dist/register/status-file/register-status-file.js +16 -7
- package/dist/register/status-file/report-model.js +293 -205
- package/dist/register/status-file/team-css.js +1 -1
- package/dist/register/status-html.js +11 -4
- package/dist/register/store.js +2 -2
- package/dist/register/workbooks.js +24 -13
- package/dist/renderer/footer-line.js +10 -2
- package/dist/renderer/tool-labels.js +2 -2
- package/dist/repl/builtin-commands.js +1 -1
- package/dist/repl/continue-skill.js +5 -0
- package/dist/repl/level-command.js +6 -15
- package/dist/repl.js +37 -14
- package/dist/router/router-core.js +5 -3
- package/dist/session/fresh-start.js +15 -0
- package/dist/skill-catalog.js +4 -7
- package/dist/tools/_filesystem-shared.js +7 -0
- package/dist/tools/_team-work-path.js +53 -0
- package/dist/tools/approval.js +97 -19
- package/dist/tools/cut-markers.js +47 -0
- package/dist/tools/filesystem/extract-document.js +50 -2
- package/dist/tools/filesystem/file-edit.js +8 -1
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +8 -1
- package/dist/tools/filesystem/read-document.js +20 -4
- package/dist/tools/project/_merge-shared.js +2 -2
- package/dist/tools/project/_team-tool-shared.js +5 -0
- package/dist/tools/project/rules_propose.js +2 -1
- package/dist/tools/project/team_findings.js +81 -28
- package/dist/tools/project/team_lane_move.js +127 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -4
- package/dist/tools/text-elements/tools.js +7 -1
- package/dist/tools/tool-loading.js +1 -1
- package/dist/tools/tool-output-read.js +5 -1
- package/dist/tools/update-method-intercept.js +18 -1
- package/dist/tools/write-pipeline.js +24 -5
- package/dist/ui/approval-modal.js +6 -3
- package/dist/ui/footer-line.js +19 -1
- package/package.json +3 -3
- package/dist/projects/merge-cca.js +0 -311
- package/dist/tools/project/cca_merge.js +0 -174
- package/test-harness/fe-draft/app.ts +0 -188
- package/test-harness/fe-draft/fe-draft.e2e.test.ts +0 -461
- package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +0 -51
- package/test-harness/fe-draft/routes.ts +0 -224
- package/test-harness/fe-draft/routes.unit.test.ts +0 -45
- package/test-harness/fe-draft/server.ts +0 -225
- package/test-harness/fe-draft/variants.ts +0 -196
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/level.js
CHANGED
|
@@ -26,7 +26,7 @@ export function parseLevel(raw) {
|
|
|
26
26
|
export const LEVEL_INVARIANT = `The level changes how much you explain, never what you found: findings, numbers, object names, warnings, risks, approvals, plan and safety prompts stay exact. It does not change what this person is allowed to do in SAP.`;
|
|
27
27
|
export const LEVEL_SENTENCES = Object.freeze({
|
|
28
28
|
learning: 'You\'re talking to a developer who is still building experience. Explain the why, not just the what. The first time a technical term comes up, say in a few words what it means. When you show a fix, point out the pattern behind it so they can use it again. Stay encouraging and concrete; never talk down. Just before you ask them to approve anything or answer a yes/no question, say in one or two plain sentences what will happen, what could go wrong and how it can be undone, then ask. Keep it short; don\'t lecture.',
|
|
29
|
-
functional: 'You\'re talking to
|
|
29
|
+
functional: 'You\'re talking to a developer who wants the business view first. Start with what it means for the business: which documents, amounts, users or reports are affected. Avoid technical terms unless they are needed; when one is needed, explain it in plain words. Show code only when they ask for it. If they ask you to write it up for a developer, write cspeach-out/developer-request-<object-or-topic>.md (if that name is taken, add -2, -3 and so on) with the sections Summary, What we found, Proposed change, How to test and Open questions. Just before you ask them to approve anything or answer a yes/no question, say in one or two plain sentences what will happen, what could go wrong and how it can be undone, then ask. Keep it short; don\'t lecture.',
|
|
30
30
|
});
|
|
31
31
|
// ---- pinned: level text composition ----
|
|
32
32
|
/** The text appended under a "Who you're talking to" heading; null for developer (today's behaviour). */
|
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';
|
|
@@ -72,6 +74,7 @@ import { loadWatchdogConfig, evaluateWatchdog } from './turn-watchdog.js';
|
|
|
72
74
|
import { maybeBuildProjectContext } from './maybe-build-project-context.js';
|
|
73
75
|
import { buildSapConnectionContext } from './sap-connection-adapter.js';
|
|
74
76
|
import { getCurrentTransport } from '../repl/current-transport.js';
|
|
77
|
+
import { projectTransport } from '../register/project-transport.js';
|
|
75
78
|
import { collectTurnAssistantText } from './turn-assistant-text.js';
|
|
76
79
|
import { checkAndMark, newGuardState } from './parallel-write-guard.js';
|
|
77
80
|
import { userPromptText } from '../session/user-prompt.js';
|
|
@@ -116,12 +119,10 @@ const SAVE_HOOK_SKILLS = new Set([
|
|
|
116
119
|
'abap-upgrade-fix',
|
|
117
120
|
'abap-upgrade-verify',
|
|
118
121
|
'abap-upgrade-merge',
|
|
119
|
-
// 2026-05-09: CCA pipeline manifest envelopes. /abap-cca-merge produces
|
|
120
|
-
// a consolidated cca-assessment from N parallel consultant slices.
|
|
121
122
|
// 2026-10-06: the thin /abap-cca (1.1.31) reads the estate inventory and
|
|
122
123
|
// writes no envelope, so it is no longer a save-hook skill. An old
|
|
123
124
|
// cca-manifest in a turn is still offered through detectArtifactSkill.
|
|
124
|
-
|
|
125
|
+
// 2026-10-20: /abap-cca-merge cut (drift spec §3.6); its name aliases to abap-cca.
|
|
125
126
|
// 2026-05-10: v0.7 trio chain. /abap-modernize emits modernize-result;
|
|
126
127
|
// /abap-test emits test-coverage. Both accept cca-assessment via --from
|
|
127
128
|
// and can fan out into further chains after their own envelope saves.
|
|
@@ -408,7 +409,7 @@ export async function maybeOfferSave(p) {
|
|
|
408
409
|
// the output carried a complete csforge:plan-manifest block. The label
|
|
409
410
|
// stays the primary trigger — it is authoritative for prose-extracted
|
|
410
411
|
// artefacts with no inline marker (spec-gap / design / estimate) and keeps
|
|
411
|
-
// merge-skill provenance exact (abap-
|
|
412
|
+
// merge-skill provenance exact (abap-upgrade-merge saves as abap-upgrade-merge).
|
|
412
413
|
// When the label would NOT fire, a recognizable manifest block in the
|
|
413
414
|
// output resolves the registry skill instead (detectArtifactSkill), so a
|
|
414
415
|
// misroute can no longer silently lose the artifact.
|
|
@@ -655,6 +656,40 @@ export async function runTurn(params) {
|
|
|
655
656
|
else
|
|
656
657
|
console.log(line);
|
|
657
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
|
+
}
|
|
658
693
|
// Guided clean-core journey (Task 3) — the journey survives only its own
|
|
659
694
|
// queued step (or a plain reply inside it); any other turn ends the chain
|
|
660
695
|
// with one line. No journey → nothing happens here.
|
|
@@ -925,6 +960,8 @@ ${imageExpansion.error}`));
|
|
|
925
960
|
const sapConnection = params.ctx.sapAlias
|
|
926
961
|
? buildSapConnectionContext(params.ctx.sapAlias, sapSystemInfo, {
|
|
927
962
|
currentTransport: getCurrentTransport() ?? null,
|
|
963
|
+
// Task 6 (project view): the project request of this folder; null outside a project.
|
|
964
|
+
projectTransport: projectTransport(params.ctx.cwd),
|
|
928
965
|
})
|
|
929
966
|
: null;
|
|
930
967
|
projectContextBlock = await maybeBuildProjectContext({
|
|
@@ -1106,7 +1143,7 @@ ${imageExpansion.error}`));
|
|
|
1106
1143
|
session.turn_seq = (session.turn_seq ?? Math.max(turnNumber - 1, session.lastAutoCompactTurn ?? 0)) + 1;
|
|
1107
1144
|
// Snapshot session.messages.length at turn start so the save hook can
|
|
1108
1145
|
// walk every assistant message added during this turn — not just the
|
|
1109
|
-
// final one. Skills like /abap-
|
|
1146
|
+
// final one. Skills like /abap-upgrade-merge emit their manifest in an early
|
|
1110
1147
|
// message before a closing ask_question widget; the final wrap-up
|
|
1111
1148
|
// message ends the turn but doesn't carry the manifest. See
|
|
1112
1149
|
// ./turn-assistant-text.ts for the full rationale.
|
|
@@ -1408,6 +1445,14 @@ ${imageExpansion.error}`));
|
|
|
1408
1445
|
}).trimEnd());
|
|
1409
1446
|
return;
|
|
1410
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
|
+
}
|
|
1411
1456
|
// Proxy returns 409 upgrade_required when CLI is older than skill's min_cli_version.
|
|
1412
1457
|
const status = err?.status ?? err?.response?.status;
|
|
1413
1458
|
const bodyRaw = err?.error ?? err?.response?.data ?? err?.body;
|
|
@@ -1658,6 +1703,15 @@ ${imageExpansion.error}`));
|
|
|
1658
1703
|
if (callIndex === 0 && currentAssistantContent.length === 0 && isChatUnavailable(err)) {
|
|
1659
1704
|
withdrawChatTurn((err?.status ?? err?.response?.status));
|
|
1660
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
|
+
}
|
|
1661
1715
|
}
|
|
1662
1716
|
// Final flush: emit any pending partial line after the stream ends.
|
|
1663
1717
|
// Runs on both clean completion and mid-stream interruption.
|
|
@@ -1804,6 +1858,18 @@ ${imageExpansion.error}`));
|
|
|
1804
1858
|
const callTokens = perCallTokens(nowTokens, lastLoggedTokens);
|
|
1805
1859
|
lastLoggedTokens = nowTokens;
|
|
1806
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;
|
|
1807
1873
|
const entry = buildCostEntry({
|
|
1808
1874
|
turn: turnNumber,
|
|
1809
1875
|
// A4 — when a per-turn override ran the stream on a different model
|
|
@@ -1830,6 +1896,8 @@ ${imageExpansion.error}`));
|
|
|
1830
1896
|
// Task 18 (F24) — running reader totals of this turn (absent until a
|
|
1831
1897
|
// reader has run).
|
|
1832
1898
|
reader: params.ctx.readerSpend,
|
|
1899
|
+
skill: mainLine ? params.skill : undefined,
|
|
1900
|
+
system_block: systemBlock,
|
|
1833
1901
|
});
|
|
1834
1902
|
readerCallsLogged = params.ctx.readerSpend?.calls ?? 0;
|
|
1835
1903
|
void appendCostLine(session.id, entry);
|
|
@@ -1869,13 +1937,25 @@ ${imageExpansion.error}`));
|
|
|
1869
1937
|
await turnStreamWriter.close(interruptedError !== null
|
|
1870
1938
|
? `_interrupted: ${session.turnInterruptedReason ?? 'unknown'}_`
|
|
1871
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);
|
|
1872
1945
|
// Guided clean-core journey (Task 3, fix round 1) — the queued step was
|
|
1873
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.
|
|
1874
1949
|
if (interruptedError !== null && !readerChild && !params.ctx.subagent) {
|
|
1875
1950
|
const journeyLine = pauseJourneyForInterruptedTurn(session);
|
|
1876
|
-
if (journeyLine && !isHeadless())
|
|
1951
|
+
if (journeyLine && !isHeadless() && !recoverTooLong)
|
|
1877
1952
|
emit(journeyLine);
|
|
1878
1953
|
}
|
|
1954
|
+
if (recoverTooLong) {
|
|
1955
|
+
promptTooLongRetried = true; // after the persist block: never a retry from here
|
|
1956
|
+
await recoverFromPromptTooLong(interruptedError);
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1879
1959
|
// Re-throw AFTER persistence so the outer REPL catch can render the
|
|
1880
1960
|
// graceful error UX with full session-id + log-path context.
|
|
1881
1961
|
if (interruptedError !== null)
|
|
@@ -1932,7 +2012,7 @@ ${imageExpansion.error}`));
|
|
|
1932
2012
|
// a background block on the next REPL prompt.
|
|
1933
2013
|
//
|
|
1934
2014
|
// 2026-05-12: collect text from ALL assistant messages added during
|
|
1935
|
-
// this turn, not just the final one. Skills like /abap-
|
|
2015
|
+
// this turn, not just the final one. Skills like /abap-upgrade-merge emit
|
|
1936
2016
|
// their manifest in an early message before a closing ask_question
|
|
1937
2017
|
// widget; the final wrap-up message ends the turn but doesn't
|
|
1938
2018
|
// carry the manifest. See ./turn-assistant-text.ts for details.
|
|
@@ -2433,10 +2513,12 @@ ${imageExpansion.error}`));
|
|
|
2433
2513
|
}
|
|
2434
2514
|
// Normal path falls through: the existing `toolResults.push(...)` block
|
|
2435
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).
|
|
2436
2518
|
toolResults.push({
|
|
2437
2519
|
type: 'tool_result',
|
|
2438
2520
|
tool_use_id: block.id,
|
|
2439
|
-
content: result.content,
|
|
2521
|
+
content: capToolResultContent(result.content),
|
|
2440
2522
|
is_error: result.is_error,
|
|
2441
2523
|
});
|
|
2442
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
|
+
}
|
|
@@ -34,6 +34,8 @@ export function buildSapConnectionContext(alias, systemInfo, state = {}) {
|
|
|
34
34
|
alias,
|
|
35
35
|
system: toSummary(systemInfo),
|
|
36
36
|
currentTransport: state.currentTransport ?? null,
|
|
37
|
+
// Only in a project folder: a context without one stays as it was, key for key.
|
|
38
|
+
...(state.projectTransport ? { projectTransport: state.projectTransport } : {}),
|
|
37
39
|
activeScope: toActiveScope(state),
|
|
38
40
|
recentObjects: state.recentObjects ?? [],
|
|
39
41
|
};
|
|
@@ -47,7 +47,7 @@ export function buildReaderSystemPrompt(opts) {
|
|
|
47
47
|
* Chat lane (spec 2026-10-17 D3) — one block, the pinned chat prompt, as the
|
|
48
48
|
* proxy sends it for the `_chat` skill header. The coding-workflow preamble is
|
|
49
49
|
* NOT sent on chat turns. A non-professional style is appended to that block;
|
|
50
|
-
* a non-
|
|
50
|
+
* a non-default level (§10 D14) follows the style text.
|
|
51
51
|
*/
|
|
52
52
|
export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAULT_LEVEL) {
|
|
53
53
|
const marker = opts.cacheMarkers ? { cache_control: { type: 'ephemeral' } } : {};
|
|
@@ -62,7 +62,7 @@ export function buildChatSystemPrompt(opts, style = DEFAULT_STYLE, level = DEFAU
|
|
|
62
62
|
* has no SKILL.md, so it never reaches the skill source (a lookup would fail);
|
|
63
63
|
* nor does the built-in `_chat` (D3). A non-professional `style` (D9) is
|
|
64
64
|
* appended to the END of the last block — professional changes nothing, and a
|
|
65
|
-
* reader (which reports facts) never gets style. A non-
|
|
65
|
+
* reader (which reports facts) never gets style. A non-default `level`
|
|
66
66
|
* (§10 D14) follows the style text; developer changes nothing; a reader never
|
|
67
67
|
* gets it.
|
|
68
68
|
*/
|
|
@@ -21,17 +21,24 @@ export const CHAT_UNIT_TEST_REASON = 'This chat reads but never runs code on SAP
|
|
|
21
21
|
export const CHAT_DISPATCH_SKILL_REASON = 'In this chat, dispatch_skill is not available. To start a guided workflow, call hand_off with the workflow name and a clear request.';
|
|
22
22
|
/** A call carrying an approval_id in chat — approvals belong to workflows, never to chat. */
|
|
23
23
|
export const CHAT_APPROVAL_ID_REASON = 'This chat never uses an approval. To make this change, call hand_off with the right workflow and a clear request — the workflow asks before it writes.';
|
|
24
|
+
/**
|
|
25
|
+
* team_lane_move in chat (Task 4, 2026-10-20) — listed like every tool, refused
|
|
26
|
+
* here. Moving packages between people is a lead's decision with its own card;
|
|
27
|
+
* in chat the lead is given the command instead.
|
|
28
|
+
*/
|
|
29
|
+
export const CHAT_LANE_MOVE_REASON = 'In this chat, packages are not moved between people. The lead runs: cspeach team lane move --packages <names> --to <name> --reason "<why>"';
|
|
24
30
|
/**
|
|
25
31
|
* Tools refused in chat beyond the generic rule: the two smokes write SAP
|
|
26
32
|
* data over OData although requiresSap is false; sap_run_unit_test runs code
|
|
27
33
|
* that can COMMIT; dispatch_skill could queue any slash command (hand_off is
|
|
28
|
-
* chat's switch, D5).
|
|
34
|
+
* chat's switch, D5); team_lane_move writes the team project's lanes (Task 4).
|
|
29
35
|
*/
|
|
30
36
|
const CHAT_EXTRA_REFUSED = new Map([
|
|
31
37
|
['fiori_draft_smoke', CHAT_READ_ONLY_REASON],
|
|
32
38
|
['cap_link_smoke', CHAT_READ_ONLY_REASON],
|
|
33
39
|
['sap_run_unit_test', CHAT_UNIT_TEST_REASON],
|
|
34
40
|
['dispatch_skill', CHAT_DISPATCH_SKILL_REASON],
|
|
41
|
+
['team_lane_move', CHAT_LANE_MOVE_REASON],
|
|
35
42
|
]);
|
|
36
43
|
/**
|
|
37
44
|
* Chat lane refusal set (D-F): WRITE_TOOLS ∪ {request_approval} ∪ every tool
|
|
@@ -256,6 +263,11 @@ function declined(r) {
|
|
|
256
263
|
export function isLocalProjectWork(tool, args) {
|
|
257
264
|
if (tool.category === 'filesystem')
|
|
258
265
|
return true;
|
|
266
|
+
// Task 4 (2026-10-20) — every project tool writes project files behind its
|
|
267
|
+
// own card (team_lane_move) or reads only: a second lane move in one turn
|
|
268
|
+
// never gets a batch card on top of its own card. No project tool reaches SAP.
|
|
269
|
+
if (tool.category === 'project')
|
|
270
|
+
return true;
|
|
259
271
|
// 2026-10-02 — fiori_deploy mode "test" (the default) writes nothing to SAP:
|
|
260
272
|
// the backend validates the upload and keeps nothing. Not a batch write, so
|
|
261
273
|
// a deploy phase shows ONE approval (the real deploy's request_approval),
|
|
@@ -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}`];
|