amsd-pipeline-shared 0.5.28 → 0.5.30
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/bin/amsd-pipeline-shared.js +14 -5
- package/docs/gateway-baseline-2.0.80.md5 +2 -0
- package/docs/gateway-files.txt +6 -0
- package/orchestrations/config/call-policy.json +4 -0
- package/orchestrations/prompts/templates/spec-review-criteria.json +5 -4
- package/orchestrations/scripts/lib/call-attempts.js +32 -0
- package/orchestrations/scripts/lib/call-failed.sh +65 -0
- package/orchestrations/scripts/lib/doctor/agent.js +5 -4
- package/orchestrations/scripts/lib/doctor/handle.js +26 -2
- package/orchestrations/scripts/lib/failure-healing.sh +2344 -0
- package/orchestrations/scripts/lib/kb_schema.py +289 -0
- package/orchestrations/scripts/lib/prelaunch.js +31 -1
- package/orchestrations/scripts/llm-handler.sh +3 -1
- package/orchestrations/scripts/spec-mode-runner.js +95 -22
- package/package.json +1 -1
|
@@ -64,12 +64,15 @@ if (cmd === 'verify') {
|
|
|
64
64
|
|
|
65
65
|
if (cmd === 'revert') {
|
|
66
66
|
if (!fs.existsSync(path.join(STATE, 'installed.json'))) fail(`nothing to revert: no amsd-pipeline-shared install recorded in ${STATE}`);
|
|
67
|
+
// a file the install did not have when this package went in (its own file, or one a pipeline release no longer carries): removed again
|
|
68
|
+
let ABSENT = []; try { ABSENT = JSON.parse(fs.readFileSync(path.join(STATE, 'installed.json'), 'utf8')).absent || []; } catch { ABSENT = []; }
|
|
69
|
+
const hadNone = (f) => !BASE[f] || ABSENT.includes(f);
|
|
67
70
|
for (const f of FILES) {
|
|
68
71
|
const b = path.join(BACKUP, f);
|
|
69
72
|
if (fs.existsSync(b)) fs.copyFileSync(b, path.join(ORCH, f));
|
|
70
|
-
else if (
|
|
73
|
+
else if (hadNone(f)) fs.rmSync(path.join(ORCH, f), { force: true });
|
|
71
74
|
}
|
|
72
|
-
const back = FILES.filter((f) => (
|
|
75
|
+
const back = FILES.filter((f) => (hadNone(f) ? fs.existsSync(path.join(ORCH, f)) : !known(f, md5(path.join(ORCH, f)))));
|
|
73
76
|
if (back.length) fail(`revert incomplete — not the released copy:\n ${back.join('\n ')}`);
|
|
74
77
|
fs.rmSync(STATE, { recursive: true, force: true });
|
|
75
78
|
process.stdout.write(`amsd-pipeline-shared: reverted ${FILES.length} file(s) to the released copies\n`);
|
|
@@ -82,8 +85,11 @@ let PREV = {}; try { PREV = JSON.parse(fs.readFileSync(path.join(STATE, 'install
|
|
|
82
85
|
const plan = FILES.map((f) => {
|
|
83
86
|
const now = md5(path.join(ORCH, f)); const ours = md5(path.join(ROOT, 'orchestrations', f));
|
|
84
87
|
if (now && now !== ours && PREV[f] === now) return { f, now, ours, state: 'previous' };
|
|
85
|
-
//
|
|
86
|
-
|
|
88
|
+
// ABSENT FROM THE INSTALL: installed — this package's own file, or one a pipeline release no longer carries because this package holds it
|
|
89
|
+
// (operator, 2026-10-23: "move it … and remove in bash pipeline" — one copy, in this package)
|
|
90
|
+
if (now === null) return { f, now, ours, state: 'new' };
|
|
91
|
+
// a file the release never had (this package's own, e.g. lib/agent-messages.js), present with other bytes
|
|
92
|
+
if (!BASE[f]) return { f, now, ours, state: now === ours ? 'current' : 'other' };
|
|
87
93
|
return { f, now, ours, state: now === ours ? 'current' : (known(f, now) ? 'released' : 'other') };
|
|
88
94
|
});
|
|
89
95
|
const other = plan.filter((p) => p.state === 'other');
|
|
@@ -103,7 +109,10 @@ for (const p of plan.filter((x) => ['released', 'new', 'previous'].includes(x.st
|
|
|
103
109
|
fs.chmodSync(dst, mode);
|
|
104
110
|
}
|
|
105
111
|
fs.mkdirSync(STATE, { recursive: true });
|
|
112
|
+
// what the install did not have before this package (kept across re-installs, so a revert removes exactly those)
|
|
113
|
+
let WAS_ABSENT = []; try { WAS_ABSENT = JSON.parse(fs.readFileSync(path.join(STATE, 'installed.json'), 'utf8')).absent || []; } catch { WAS_ABSENT = []; }
|
|
114
|
+
const absent = [...new Set([...WAS_ABSENT, ...plan.filter((p) => p.state === 'new').map((p) => p.f)])].sort();
|
|
106
115
|
fs.writeFileSync(path.join(STATE, 'installed.json'), `${JSON.stringify({ package: PKG.name, version: PKG.version,
|
|
107
|
-
files: Object.fromEntries(FILES.map((f) => [f, md5(path.join(ROOT, 'orchestrations', f))])) }, null, 2)}\n`);
|
|
116
|
+
files: Object.fromEntries(FILES.map((f) => [f, md5(path.join(ROOT, 'orchestrations', f))])), absent }, null, 2)}\n`);
|
|
108
117
|
const n = plan.filter((x) => ['released', 'new', 'previous'].includes(x.state)).length;
|
|
109
118
|
process.stdout.write(`amsd-pipeline-shared ${PKG.version} → ${ORCH}: ${n} file(s) replaced, ${FILES.length - n} already current\n`);
|
|
@@ -245,3 +245,5 @@ fac1b26443bb52ba45ba072f4d654920 scripts/lib/story-guards.sh
|
|
|
245
245
|
ada237373615a26d924226184ed7614b scripts/lib/prd-change-review.sh
|
|
246
246
|
c2dfd2381db0242fdbc487a948fd7feb scripts/lib/agent-si.js
|
|
247
247
|
03bde875977ab741c54c3b38a236a496 scripts/pre-run-reset.sh
|
|
248
|
+
7600f6665c5a9f24dde8969b4c013aa4 scripts/lib/failure-healing.sh
|
|
249
|
+
f5f3f2ffcf1da723455115e856dc173f scripts/lib/kb_schema.py
|
package/docs/gateway-files.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
config/call-policy.json
|
|
1
2
|
config/doctor-heavy-jobs.json
|
|
2
3
|
config/doctor-signatures.json
|
|
3
4
|
config/model-pricing.json
|
|
@@ -154,6 +155,8 @@ scripts/lib/agent-ladder.sh
|
|
|
154
155
|
scripts/lib/agent-messages.js
|
|
155
156
|
scripts/lib/agent-si.js
|
|
156
157
|
scripts/lib/agent-si.js
|
|
158
|
+
scripts/lib/call-attempts.js
|
|
159
|
+
scripts/lib/call-failed.sh
|
|
157
160
|
scripts/lib/checkpoints.sh
|
|
158
161
|
scripts/lib/codeline-lanes.sh
|
|
159
162
|
scripts/lib/codeline-packages.js
|
|
@@ -172,6 +175,7 @@ scripts/lib/doctor/cli.js
|
|
|
172
175
|
scripts/lib/doctor/crash-record.js
|
|
173
176
|
scripts/lib/doctor/handle.js
|
|
174
177
|
scripts/lib/doctor/profile.js
|
|
178
|
+
scripts/lib/doctor/release.js
|
|
175
179
|
scripts/lib/doctor/repair.js
|
|
176
180
|
scripts/lib/doctor/restarter.js
|
|
177
181
|
scripts/lib/doctor/state.js
|
|
@@ -180,6 +184,7 @@ scripts/lib/engine-paths.sh
|
|
|
180
184
|
scripts/lib/engine-prompt.js
|
|
181
185
|
scripts/lib/env-file.sh
|
|
182
186
|
scripts/lib/evidence-windows.js
|
|
187
|
+
scripts/lib/failure-healing.sh
|
|
183
188
|
scripts/lib/failure-signature.js
|
|
184
189
|
scripts/lib/flags.sh
|
|
185
190
|
scripts/lib/git-ops.sh
|
|
@@ -212,6 +217,7 @@ scripts/lib/kb-arbitration.js
|
|
|
212
217
|
scripts/lib/kb-cli.js
|
|
213
218
|
scripts/lib/kb-store.js
|
|
214
219
|
scripts/lib/kb-synthesizer.js
|
|
220
|
+
scripts/lib/kb_schema.py
|
|
215
221
|
scripts/lib/langfuse-emit.js
|
|
216
222
|
scripts/lib/llm-settings-resolve.js
|
|
217
223
|
scripts/lib/mint-and-spec.sh
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "HOW MANY TIMES ONE AGENTIC CALL IS ASKED — one value, read by every gateway through scripts/lib/call-attempts.js: the bash hub (llm-handler.sh), the failed-call step (lib/call-failed.sh), the doctor's repair agent (lib/doctor/handle.js), codeline discovery's wall-clock bound, and the Claude workflow's gateway and workflow runner (amsd-pipeline-workflow generator/gateway.js callMaxAttempts). EPAM_CALL_MAX_ATTEMPTS, when a run sets it, wins (an operator's knob for one run, never a second default).",
|
|
3
|
+
"maxAttempts": 3
|
|
4
|
+
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"observable checks live in verificationCriteria. Asked anyway, the reviewer reported the",
|
|
9
9
|
"empty array as a hallucination or a persistence failure and lowered qualityScore for it,",
|
|
10
10
|
"which is a gate blocking on a policy it was not told about.",
|
|
11
|
-
"v2 (operator, 2026-10-22): plan and criteria are reconciled before pause 2 — run 11's gotransit plan said 'no change' while its own criterion asked for a toast longer than 5 s; nothing checked one against the other."
|
|
11
|
+
"v2 (operator, 2026-10-22): plan and criteria are reconciled before pause 2 — run 11's gotransit plan said 'no change' while its own criterion asked for a toast longer than 5 s; nothing checked one against the other.",
|
|
12
|
+
"v3 (operator, 2026-10-23): the unmet criteria are a required list in the answer (unmetCriteria) — live, 1 of 3 real reviews of run 13 flagged the same contrast gap as advisory under its own name, and nothing corrected it."
|
|
12
13
|
],
|
|
13
14
|
"description": "The criteria a spec-review coordinator judges a story's collaborative spec work against.",
|
|
14
15
|
"id": "spec-review-criteria",
|
|
@@ -16,9 +17,9 @@
|
|
|
16
17
|
"spec-coordinator"
|
|
17
18
|
],
|
|
18
19
|
"placeholders": [],
|
|
19
|
-
"version":
|
|
20
|
+
"version": 3,
|
|
20
21
|
"bodies": {
|
|
21
|
-
"brownfield": "For each story, evaluate the quality of the collaborative spec work:\n1. Did both agents add meaningful, non-overlapping value?\n2. Flag any story needing human review.\n3. PLAN ALIGNMENT — where planAlignmentEvidence is present, it tells you what the\n code-graph-detective SAID it would investigate (detectivePlan) and a deterministic,\n cheap signal (aligned: true/false) for whether the final fixSiteAnalysis shares any\n named symbol/file with that plan. This signal is NOT your verdict — it is evidence, the\n same as MANIFEST EVIDENCE below. A plan can legitimately turn out wrong once real\n exploration starts. Read detectivePlan and fixSiteAnalysis yourself and judge: did the\n detective explain WHY it moved from its plan to its final answer (a stated pivot,\n reasoning that connects the two), or did the answer just change with no explanation?\n The latter is a real defect (a fix has shipped that shared no term with the detective's\n own stated plan, with no explanation, more than once on the same ticket) — set\n planAlignment to justified_deviation when the detective justifies its pivot, or\n unexplained_mismatch when it does not, and say why in reviewNotes. aligned:true needs\n no action.\n\n(Brownfield tickets never split and their acceptance criteria are immutable —\ndo not evaluate split quality or AC completeness; there is nothing there for\neither agent to have changed.\n\nEXPECTED, NOT A DEFECT: an agent's notes may describe acceptance criteria it\nauthored while the story's acceptanceCriteria array is empty, and report\nacceptanceChanged=false. That is this pipeline REDACTING them on purpose: on a\nbrownfield ticket the ACs are the ticket's own, and a thin ticket legitimately\nhas none. The observable checks live in verificationCriteria — judge THOSE.\nDo not report the mismatch as a hallucination, a persistence failure or a\nmetadata inconsistency, and do not lower qualityScore for it. An empty\nacceptanceCriteria array on a brownfield story is correct by policy.)\n\nEVERY VERIFICATION CRITERION HAS A PLANNED CHANGE THAT MAKES IT TRUE. For each verification criterion, name what in the plan makes it observable in this codeline: a fix site whose changeRequired is true, this codeline's own change or update in overallPlanForThisCodeline, or — where this codeline consumes another's package — the provider's change it receives, which its Writer is tested against through the provider's build the pipeline places in this codeline. A criterion nothing planned makes true cannot be met by the writer: flag it as criterion-without-planned-change, severity blocking, with a why that quotes the criterion and names the change that would meet it, or says that the criterion asks for something this story does not change and should be dropped. Do not approve a spec with such a criterion.\n",
|
|
22
|
-
"greenfield": "For each story, evaluate the quality of the collaborative spec work:\n1. Did both agents add meaningful, non-overlapping value?\n2. Are the acceptance criteria complete, testable, and non-overlapping?\n3. Are story splits logical and properly scoped?\n4. Flag any story needing human review.\n\nEVERY VERIFICATION CRITERION HAS A PLANNED CHANGE THAT MAKES IT TRUE. For each verification criterion, name what in the plan makes it observable in this codeline: a fix site whose changeRequired is true, this codeline's own change or update in overallPlanForThisCodeline, or — where this codeline consumes another's package — the provider's change it receives, which its Writer is tested against through the provider's build the pipeline places in this codeline. A criterion nothing planned makes true cannot be met by the writer: flag it as criterion-without-planned-change, severity blocking, with a why that quotes the criterion and names the change that would meet it, or says that the criterion asks for something this story does not change and should be dropped. Do not approve a spec with such a criterion.\n"
|
|
22
|
+
"brownfield": "For each story, evaluate the quality of the collaborative spec work:\n1. Did both agents add meaningful, non-overlapping value?\n2. Flag any story needing human review.\n3. PLAN ALIGNMENT — where planAlignmentEvidence is present, it tells you what the\n code-graph-detective SAID it would investigate (detectivePlan) and a deterministic,\n cheap signal (aligned: true/false) for whether the final fixSiteAnalysis shares any\n named symbol/file with that plan. This signal is NOT your verdict — it is evidence, the\n same as MANIFEST EVIDENCE below. A plan can legitimately turn out wrong once real\n exploration starts. Read detectivePlan and fixSiteAnalysis yourself and judge: did the\n detective explain WHY it moved from its plan to its final answer (a stated pivot,\n reasoning that connects the two), or did the answer just change with no explanation?\n The latter is a real defect (a fix has shipped that shared no term with the detective's\n own stated plan, with no explanation, more than once on the same ticket) — set\n planAlignment to justified_deviation when the detective justifies its pivot, or\n unexplained_mismatch when it does not, and say why in reviewNotes. aligned:true needs\n no action.\n\n(Brownfield tickets never split and their acceptance criteria are immutable —\ndo not evaluate split quality or AC completeness; there is nothing there for\neither agent to have changed.\n\nEXPECTED, NOT A DEFECT: an agent's notes may describe acceptance criteria it\nauthored while the story's acceptanceCriteria array is empty, and report\nacceptanceChanged=false. That is this pipeline REDACTING them on purpose: on a\nbrownfield ticket the ACs are the ticket's own, and a thin ticket legitimately\nhas none. The observable checks live in verificationCriteria — judge THOSE.\nDo not report the mismatch as a hallucination, a persistence failure or a\nmetadata inconsistency, and do not lower qualityScore for it. An empty\nacceptanceCriteria array on a brownfield story is correct by policy.)\n\nEVERY VERIFICATION CRITERION HAS A PLANNED CHANGE THAT MAKES IT TRUE. For each verification criterion, name what in the plan makes it observable in this codeline: a fix site whose changeRequired is true, this codeline's own change or update in overallPlanForThisCodeline, or — where this codeline consumes another's package — the provider's change it receives, which its Writer is tested against through the provider's build the pipeline places in this codeline. A criterion nothing planned makes true cannot be met by the writer: flag it as criterion-without-planned-change, severity blocking, with a why that quotes the criterion and names the change that would meet it, or says that the criterion asks for something this story does not change and should be dropped. Do not approve a spec with such a criterion. List every such criterion in unmetCriteria — the criterion quoted, and why — for every story you review; an empty list states that every criterion has a planned change behind it.\n",
|
|
23
|
+
"greenfield": "For each story, evaluate the quality of the collaborative spec work:\n1. Did both agents add meaningful, non-overlapping value?\n2. Are the acceptance criteria complete, testable, and non-overlapping?\n3. Are story splits logical and properly scoped?\n4. Flag any story needing human review.\n\nEVERY VERIFICATION CRITERION HAS A PLANNED CHANGE THAT MAKES IT TRUE. For each verification criterion, name what in the plan makes it observable in this codeline: a fix site whose changeRequired is true, this codeline's own change or update in overallPlanForThisCodeline, or — where this codeline consumes another's package — the provider's change it receives, which its Writer is tested against through the provider's build the pipeline places in this codeline. A criterion nothing planned makes true cannot be met by the writer: flag it as criterion-without-planned-change, severity blocking, with a why that quotes the criterion and names the change that would meet it, or says that the criterion asks for something this story does not change and should be dropped. Do not approve a spec with such a criterion. List every such criterion in unmetCriteria — the criterion quoted, and why — for every story you review; an empty list states that every criterion has a planned change behind it.\n"
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HOW MANY TIMES ONE AGENTIC CALL IS ASKED — the one reader of config/call-policy.json (see that file). Every gateway calls it.
|
|
4
|
+
*
|
|
5
|
+
* callMaxAttempts(env = process.env) → an integer >= 1: EPAM_CALL_MAX_ATTEMPTS when the environment sets it, else the policy's maxAttempts
|
|
6
|
+
* node call-attempts.js → prints it; exit 1 with the reason when it cannot be read
|
|
7
|
+
*
|
|
8
|
+
* A value that is not a whole number of at least one, or a policy file it cannot read, is a stated error — never a default of its own.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const POLICY = path.join(__dirname, '..', '..', 'config', 'call-policy.json');
|
|
14
|
+
|
|
15
|
+
function whole(v, where) {
|
|
16
|
+
const n = Number(v);
|
|
17
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`the call attempt count ${where} is '${v}', not a whole number of at least 1`);
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function callMaxAttempts(env = process.env) {
|
|
22
|
+
if (env.EPAM_CALL_MAX_ATTEMPTS !== undefined && env.EPAM_CALL_MAX_ATTEMPTS !== '') return whole(env.EPAM_CALL_MAX_ATTEMPTS, 'in EPAM_CALL_MAX_ATTEMPTS');
|
|
23
|
+
let policy;
|
|
24
|
+
try { policy = JSON.parse(fs.readFileSync(POLICY, 'utf8')); } catch (e) { throw new Error(`the call policy ${POLICY} is not readable JSON: ${e.message}`); }
|
|
25
|
+
return whole(policy.maxAttempts, `in ${POLICY} (maxAttempts)`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (require.main === module) {
|
|
29
|
+
try { process.stdout.write(`${callMaxAttempts()}\n`); } catch (e) { process.stderr.write(`${e.message}\n`); process.exit(1); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { callMaxAttempts, POLICY };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# THE FAILED-CALL STEP, ONCE, FOR EVERY AGENTIC CALL (operator, 2026-10-23: "All agentic calls - need self heal integration and retries - all - no
|
|
3
|
+
# exceptions"). An agent call that gave no usable answer, as the pipeline's gateway takes it (llm-handler.sh "UNIFORM CALL RESILIENCE"), by the
|
|
4
|
+
# pipeline's own functions: the failure on the agent's ladder and the next rung (lib/agent-ladder.sh, the chains exported by lib/model-ladders.sh
|
|
5
|
+
# from the project's settings), the previous-refusal block for the next prompt (engine template previous-refusal), and the KB episode
|
|
6
|
+
# (lib/kb-apply.sh) so self-heal learns from it. No AI.
|
|
7
|
+
# call-failed.sh env CALL_AGENT (the agent), CALL_MODEL (the model just used), CALL_STORY, CALL_REASON, CALL_ATTEMPT,
|
|
8
|
+
# CALL_STATE (the folder its ladder state and records live in), EPAM_PROJECT_CONFIG_DIR (the project's settings; optional)
|
|
9
|
+
# Prints {"model", "decision": "escalating|top|no-chain", "block", "episode": bool, "line"} and appends the same as one call-failed event to
|
|
10
|
+
# $CALL_STATE/events.jsonl; "line" is the hub's own attempt line, out of the call policy's count (lib/call-attempts.js). On a library that
|
|
11
|
+
# does not load, or a count it cannot read: {"error": true, "reason"}.
|
|
12
|
+
set -uo pipefail
|
|
13
|
+
S="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
14
|
+
: "${CALL_AGENT:?}" "${CALL_STATE:?}" "${CALL_MODEL:=}" "${CALL_STORY:=global}" "${CALL_REASON:=the agent produced no usable answer}" "${CALL_ATTEMPT:=1}"
|
|
15
|
+
export LOG_DIR="$CALL_STATE"; mkdir -p "$LOG_DIR"
|
|
16
|
+
export NODE_BIN="${NODE_BIN:-$(command -v node)}"
|
|
17
|
+
_log="$LOG_DIR/call-failed.log"
|
|
18
|
+
{
|
|
19
|
+
# shellcheck source=/dev/null
|
|
20
|
+
. "$S/lib/model-ladders.sh"
|
|
21
|
+
# the project's chains (EPAM_MODEL_LADDER_<TIER>); none when no project settings are named — the agent then keeps its model
|
|
22
|
+
if [ -n "${EPAM_PROJECT_CONFIG_DIR:-}" ] && [ -f "$EPAM_PROJECT_CONFIG_DIR/llm-settings.json" ]; then export_model_ladders "$EPAM_PROJECT_CONFIG_DIR/llm-settings.json"; fi
|
|
23
|
+
# shellcheck source=/dev/null
|
|
24
|
+
. "$S/lib/agent-ladder.sh"
|
|
25
|
+
# shellcheck source=/dev/null
|
|
26
|
+
. "$S/lib/kb-apply.sh"
|
|
27
|
+
} >>"$_log" 2>&1 || { jq -cn --rawfile m "$_log" '{error:true, reason:"the pipeline ladder/KB libraries did not load", message:$m}'; exit 0; }
|
|
28
|
+
declare -F agent_ladder_model >/dev/null || { jq -cn '{error:true, reason:"agent_ladder_model is not defined"}'; exit 0; }
|
|
29
|
+
max="$("$NODE_BIN" "$S/lib/call-attempts.js" 2>>"$_log")" || { jq -cn --rawfile m "$_log" '{error:true, reason:"no call attempt count", message:$m}'; exit 0; }
|
|
30
|
+
|
|
31
|
+
agent_ladder_record_failure "$CALL_AGENT" "$CALL_STORY"
|
|
32
|
+
next="$(agent_ladder_model "$CALL_AGENT" "$CALL_STORY" "$CALL_MODEL" 2>>"$_log")"
|
|
33
|
+
# after a recorded failure the ladder either names the next rung or none — its top, or no chain for the tier — and the same rung is retried;
|
|
34
|
+
# said as the hub says it (llm-handler.sh "UNIFORM CALL RESILIENCE")
|
|
35
|
+
n="$((CALL_ATTEMPT + 1))/${max}"
|
|
36
|
+
if [ -n "$next" ] && [ "$next" != "$CALL_MODEL" ]; then
|
|
37
|
+
decision=escalating; line="[ai-run] attempt ${n} for '${CALL_AGENT}' — escalating ${CALL_MODEL} -> ${next}"
|
|
38
|
+
elif agent_ladder_exhausted "$CALL_AGENT" "$CALL_STORY" "$CALL_MODEL" 2>>"$_log"; then
|
|
39
|
+
decision=top; next="$CALL_MODEL"; line="[ai-run] attempt ${n} for '${CALL_AGENT}' — at the top of its declared chain (${CALL_MODEL:-default}), retrying the same rung"
|
|
40
|
+
else
|
|
41
|
+
decision=no-chain; next="$CALL_MODEL"; line="[ai-run] attempt ${n} for '${CALL_AGENT}' — no chain declared for this agent's tier, retrying on ${CALL_MODEL:-default}"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# the hub's _ai_prompt_with_failure: never a credential in a prompt; bounded, the tail kept
|
|
45
|
+
why="$(printf '%s' "$CALL_REASON" | sed -E 's/(sk-[A-Za-z0-9_-]{8,}|ey[A-Za-z0-9_-]{20,}|[A-Za-z0-9_-]{32,})/[REDACTED]/g' | tail -c 1200)"
|
|
46
|
+
block="$("$NODE_BIN" -e '
|
|
47
|
+
try {
|
|
48
|
+
const { renderEngineTemplate } = require(process.argv[1] + "/lib/engine-prompt.js");
|
|
49
|
+
process.stdout.write(renderEngineTemplate("previous-refusal", { __REASON__: process.argv[2], __ARTEFACT__: process.argv[3] || "answer" }));
|
|
50
|
+
} catch (e) { process.stdout.write(""); }
|
|
51
|
+
' "$S" "$why" "$CALL_AGENT" 2>>"$_log")"
|
|
52
|
+
|
|
53
|
+
# a block that did not render is said, never sent silently empty (the renderer needs the install's seam registry beside these scripts)
|
|
54
|
+
[ -n "$block" ] || { jq -cn --arg agent "$CALL_AGENT" --rawfile m "$_log" '{error:true, reason:("the previous-refusal block did not render for " + $agent + " — is this the installed component, with its seam registry?"), message:$m}'; exit 0; }
|
|
55
|
+
# kb_record_episode never fails its caller (losing an episode must not fail a run); it records only when the KB tool is installed beside it —
|
|
56
|
+
# so that is what "episode" says (a write error inside the store is swallowed by the pipeline's own function, by its design)
|
|
57
|
+
printf '%s' "$CALL_REASON" | kb_record_episode "$CALL_STORY" "$CALL_AGENT" "model call produced no usable output" 2>>"$_log"
|
|
58
|
+
episode=$([ -f "$S/lib/kb-cli.js" ] && echo true || echo false)
|
|
59
|
+
printf '%s\n' "$line" >>"$_log"
|
|
60
|
+
jq -cn --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg agent "$CALL_AGENT" --arg story "$CALL_STORY" --argjson attempt "$CALL_ATTEMPT" \
|
|
61
|
+
--arg from "$CALL_MODEL" --arg to "$next" --arg decision "$decision" --argjson episode "$episode" --arg reason "$CALL_REASON" \
|
|
62
|
+
'{ts:$ts, kind:"call-failed", agent:$agent, story:$story, attempt:$attempt, from:$from, to:$to, decision:$decision, episode:$episode, reason:$reason}' \
|
|
63
|
+
>> "$LOG_DIR/events.jsonl"
|
|
64
|
+
jq -cn --arg model "$next" --arg decision "$decision" --arg block "$block" --argjson episode "$episode" --arg line "$line" \
|
|
65
|
+
'{model:$model, decision:$decision, block:$block, episode:$episode, line:$line}'
|
|
@@ -44,8 +44,9 @@ function renderPrompt(vars, profile) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** The command, prepared and not started: the CLI in print mode on the profile's family, in the work tree, memory-bounded when the host has the wrapper. */
|
|
47
|
-
function buildCommand({ install, profile, prompt, cwd, claudeBin, budgetUsd }) {
|
|
48
|
-
|
|
47
|
+
function buildCommand({ install, profile, prompt, cwd, claudeBin, budgetUsd, model: rung }) {
|
|
48
|
+
// a rung the failed-call step moved to is taken only within the profile's family; otherwise the family's own model
|
|
49
|
+
const model = rung && rung.toLowerCase().includes(profile.model.family.toLowerCase()) ? rung : modelFor(install, profile);
|
|
49
50
|
const argv = [...hostWrap(), claudeBin || process.env.CLAUDE_BIN || 'claude', '-p', prompt, '--model', model, '--output-format', 'json',
|
|
50
51
|
'--setting-sources', 'project', '--permission-mode', 'bypassPermissions',
|
|
51
52
|
...(budgetUsd ? ['--max-budget-usd', String(budgetUsd)] : [])];
|
|
@@ -77,8 +78,8 @@ function readAnswer(text) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Runs the session (the only model call the doctor makes). Returns the answer, the session's cost record and any model of another family it used. */
|
|
80
|
-
function runAgent({ install, profile, prompt, cwd, sessionDir, env, claudeBin, budgetUsd }) {
|
|
81
|
-
const cmd = buildCommand({ install, profile, prompt, cwd, claudeBin, budgetUsd });
|
|
81
|
+
function runAgent({ install, profile, prompt, cwd, sessionDir, env, claudeBin, budgetUsd, model }) {
|
|
82
|
+
const cmd = buildCommand({ install, profile, prompt, cwd, claudeBin, budgetUsd, model });
|
|
82
83
|
const r = spawnSync(cmd.argv[0], cmd.argv.slice(1), { cwd: cmd.cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: '0', ...process.env, ...(env || {}) }, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
83
84
|
try { fs.mkdirSync(sessionDir, { recursive: true }); fs.writeFileSync(path.join(sessionDir, 'agent.stderr'), String(r.stderr || '')); fs.writeFileSync(path.join(sessionDir, 'agent.stdout'), String(r.stdout || '')); } catch { /* evidence only */ }
|
|
84
85
|
let out = null; try { out = JSON.parse(r.stdout); } catch { out = null; }
|
|
@@ -15,9 +15,19 @@ const { settings, open } = require('./state.js');
|
|
|
15
15
|
const { verifyRepair, reinstall, installedMismatches, checkComponent, snapshotRepos, repoChanges, promote } = require('./repair.js');
|
|
16
16
|
const { renderPrompt } = require('./agent.js');
|
|
17
17
|
const { restart } = require('./restarter.js');
|
|
18
|
+
const { callMaxAttempts } = require('../call-attempts.js'); // the hub's count: the call policy's one value
|
|
18
19
|
|
|
19
20
|
const git = (repo, ...a) => spawnSync('git', ['-C', repo, ...a], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
20
21
|
const tail = (file, n) => { try { return fs.readFileSync(file, 'utf8').split('\n').slice(-n - 1).join('\n'); } catch { return ''; } };
|
|
22
|
+
/** The shared failed-call step for one failed session (lib/call-failed.sh): the project's KB when the run names a project, else the doctor's own folder. */
|
|
23
|
+
function callFailed({ agent, model, story, attempt, reason, state, values }) {
|
|
24
|
+
const proj = (values && values.EPAM_PROJECT_CONFIG_DIR) || '';
|
|
25
|
+
const env = { ...process.env, CALL_AGENT: agent, CALL_MODEL: model, CALL_STORY: story, CALL_ATTEMPT: String(attempt), CALL_REASON: reason, CALL_STATE: state,
|
|
26
|
+
NODE_BIN: process.execPath, ...(proj ? { EPAM_PROJECT_CONFIG_DIR: proj } : { KB_ROOT: path.join(state, 'kb') }) };
|
|
27
|
+
if (!proj) delete env.EPAM_PROJECT_CONFIG_DIR;
|
|
28
|
+
const r = spawnSync('bash', [path.join(__dirname, '..', 'call-failed.sh')], { env, encoding: 'utf8' });
|
|
29
|
+
try { return JSON.parse(String(r.stdout).trim().split('\n').pop()); } catch { return { error: true, reason: `the failed-call step gave no result (exit ${r.status}): ${String(r.stderr).slice(-300)}` }; }
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
async function handle(a) {
|
|
23
33
|
const { profile, runDir, outDir, install, values, runId, res, crash, aborted, since, protectedRepos, agent, relaunch } = a;
|
|
@@ -78,8 +88,22 @@ async function handle(a) {
|
|
|
78
88
|
|
|
79
89
|
const before = snapshotRepos(protectedRepos || []);
|
|
80
90
|
d.event('agent', { branch, base }, attempt.n);
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
// SELF-HEAL FOR THE DOCTOR'S OWN AGENT (operator, 2026-10-23: "All agentic calls - need self heal integration and retries - all - no
|
|
92
|
+
// exceptions"): a session that gave no readable answer goes to the shared failed-call step (lib/call-failed.sh — the ladder, the
|
|
93
|
+
// previous-refusal block, the KB episode), and is asked again with that block on the rung it names, up to EPAM_CALL_MAX_ATTEMPTS
|
|
94
|
+
const attempts = callMaxAttempts();
|
|
95
|
+
const healDir = path.join(A, 'call-failed');
|
|
96
|
+
let r; let p = prompt; let rung;
|
|
97
|
+
for (let i = 1; ; i += 1) {
|
|
98
|
+
r = await agent({ prompt: p, cwd: wt, sessionDir: A, install, profile, budgetUsd: cfg.budget, ...(rung ? { model: rung } : {}) });
|
|
99
|
+
if (r.session) for (const [model, u] of Object.entries(r.session.modelUsage || {})) d.cost({ attempt: attempt.n, call: i, model, tokensIn: u.inputTokens || 0, tokensOut: u.outputTokens || 0, costUsd: u.costUSD || 0 });
|
|
100
|
+
if (r.answer || i >= attempts) break;
|
|
101
|
+
const h = callFailed({ agent: `${profile.component.name}-doctor`, model: r.model, story: runId, attempt: i,
|
|
102
|
+
reason: `the repair session ended without a readable answer (exit ${r.exit})`, state: healDir, values });
|
|
103
|
+
d.event('call-failed', { attempt: i, decision: h.decision, from: r.model, to: h.model || '', episode: !!h.episode, error: h.error ? h.reason : undefined }, attempt.n);
|
|
104
|
+
if (h.model) rung = h.model;
|
|
105
|
+
p = prompt + (h.block || '');
|
|
106
|
+
}
|
|
83
107
|
if (r.otherFamily && r.otherFamily.length) { d.event('non-family-model', { models: r.otherFamily }, attempt.n); alert(`warning: the session used a model that is not ${profile.model.family}: ${r.otherFamily.join(', ')}`); }
|
|
84
108
|
|
|
85
109
|
// CLIENT CODE IS NEVER THE DOCTOR'S: any change in any protected repository during the session refuses the whole repair
|