@miller-tech/uap 1.100.0 → 1.101.1

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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Blackboard Task Orchestrator (P1) + Minimal Context Assembler (P2)
3
+ *
4
+ * The mechanism that lets a small-context model multi-step through a large
5
+ * build: instead of one convergence loop holding the WHOLE design in context,
6
+ * the orchestrator executes a task DAG where each leaf task runs in a FRESH,
7
+ * MINIMAL context assembled from externalized state — its own goal, its
8
+ * acceptance criteria, and ONLY the recorded outputs of its direct
9
+ * dependencies (not the full mission, not every prior summary).
10
+ *
11
+ * State lives on a "blackboard" (completed task outcomes), not in the prompt.
12
+ * A task that finishes writes a compact summary (P1) — and later its verified
13
+ * interface contract (P4) — back to the blackboard, so a dependent task loads
14
+ * a few hundred tokens of "what already exists" rather than re-reading source.
15
+ *
16
+ * This module is executor-agnostic: it takes a `runTask` callback that turns
17
+ * an assembled prompt into a pass/fail outcome (production wires the
18
+ * ConvergenceLoop; tests inject a deterministic stub). That keeps the graph
19
+ * logic unit-testable without a model.
20
+ */
21
+ import { topoOrder } from './decompose.js';
22
+ const DEFAULT_DEP_SUMMARY_CHARS = 240;
23
+ /** A short mission line — orientation, not the full spec. */
24
+ const MISSION_SNIPPET_CHARS = 300;
25
+ const DEFAULT_CONTEXT_BUDGET_CHARS = 6000;
26
+ const DEFAULT_MAX_TASKS = 40;
27
+ /**
28
+ * P6 — enforce the context budget: if the assembled prompt exceeds `budget`,
29
+ * drop whole dependency lines from the END (furthest/least-recent deps) until
30
+ * it fits, appending a note about what was elided. Pure + unit-tested; the
31
+ * governor turns "minimal context" into a hard invariant.
32
+ */
33
+ export function governContext(prompt, depLineCount, budget) {
34
+ if (prompt.length <= budget)
35
+ return { prompt, droppedDeps: 0 };
36
+ const lines = prompt.split('\n');
37
+ // dependency lines are the "- <id>: ..." entries under ALREADY BUILT.
38
+ let dropped = 0;
39
+ for (let i = lines.length - 1; i >= 0 && lines.join('\n').length > budget && dropped < depLineCount; i--) {
40
+ if (/^- [^:]+: /.test(lines[i])) {
41
+ lines.splice(i, 1);
42
+ dropped++;
43
+ }
44
+ }
45
+ let out = lines.join('\n');
46
+ if (dropped > 0)
47
+ out += `\n(context governor: elided ${dropped} lower-priority dependency summary(ies) to fit the ${budget}-char budget)`;
48
+ // Last resort: hard truncate if still over (e.g. a single huge goal).
49
+ if (out.length > budget)
50
+ out = out.slice(0, budget) + '\n…(truncated to context budget)…';
51
+ return { prompt: out, droppedDeps: dropped };
52
+ }
53
+ /**
54
+ * P2 — assemble one task's MINIMAL context: a short mission line, the task's
55
+ * own goal + criteria + declared files, and ONLY the summaries/contracts of
56
+ * its DIRECT dependencies (pulled from the blackboard). Pure and unit-tested;
57
+ * this is the function that keeps a small model's window optimal.
58
+ */
59
+ export function assembleTaskContext(task, mission, blackboard, maxDepSummaryChars = DEFAULT_DEP_SUMMARY_CHARS, opts = {}) {
60
+ const sections = [];
61
+ // Orientation only — a snippet, never the whole mission.
62
+ sections.push(`OBJECTIVE (context, one line): ${mission.slice(0, MISSION_SNIPPET_CHARS)}`);
63
+ sections.push('');
64
+ sections.push(`YOUR TASK — ${task.title}:`);
65
+ sections.push(task.goal);
66
+ if (task.files && task.files.length > 0) {
67
+ sections.push('');
68
+ sections.push(`Files for THIS task: ${task.files.join(', ')} (edit only these).`);
69
+ }
70
+ if (task.criteria && task.criteria.length > 0) {
71
+ sections.push('');
72
+ sections.push('This task is done when:');
73
+ task.criteria.forEach((c, i) => sections.push(` ${i + 1}. ${c}`));
74
+ }
75
+ // P3 — relevant persisted design decisions (objective/architecture): a
76
+ // small retrieval, not the full spec dump.
77
+ if (opts.designLines && opts.designLines.length > 0) {
78
+ sections.push('');
79
+ sections.push('DESIGN CONTEXT (established decisions — honor them):');
80
+ opts.designLines.slice(0, 6).forEach((d) => sections.push(`- ${d}`));
81
+ }
82
+ // ONLY direct dependencies' outputs — the crux of minimal context. A task
83
+ // that depends on 'store' loads store's contract/summary, not store's source
84
+ // and not the other 12 tasks. Dependents read the interface, not the tree.
85
+ const includedDeps = [];
86
+ const deps = (task.deps ?? []).filter((d) => blackboard.has(d));
87
+ if (deps.length > 0) {
88
+ sections.push('');
89
+ sections.push('ALREADY BUILT — build ON these, do not reimplement them:');
90
+ for (const depId of deps) {
91
+ const out = blackboard.get(depId);
92
+ includedDeps.push(depId);
93
+ const body = (out.contract ?? out.summary).slice(0, maxDepSummaryChars);
94
+ sections.push(`- ${depId}: ${body}`);
95
+ }
96
+ }
97
+ const raw = sections.join('\n');
98
+ const governed = governContext(raw, includedDeps.length, opts.budgetChars ?? DEFAULT_CONTEXT_BUDGET_CHARS);
99
+ return { taskId: task.id, prompt: governed.prompt, includedDeps };
100
+ }
101
+ /**
102
+ * P1 — execute the task DAG on a blackboard. Topologically ordered; a task
103
+ * runs only after every dependency has SUCCEEDED. A failed task blocks its
104
+ * dependents (their deps aren't all green) and they are reported as skipped/
105
+ * failed rather than run against incomplete state. Fail-soft on publish.
106
+ */
107
+ export async function orchestrate(config) {
108
+ const maxTasks = config.maxTasks ?? DEFAULT_MAX_TASKS;
109
+ const budget = config.contextBudgetChars;
110
+ // Mutable queue so P5 re-planning can append discovered subtasks; re-sorted
111
+ // topologically each time the set grows.
112
+ let queue = topoOrder(config.tasks);
113
+ const known = new Set(queue.map((t) => t.id));
114
+ const blackboard = new Map();
115
+ const done = new Set();
116
+ const failed = new Set();
117
+ const outcomes = [];
118
+ let turns = 0;
119
+ for (let qi = 0; qi < queue.length; qi++) {
120
+ const task = queue[qi];
121
+ const deps = task.deps ?? [];
122
+ // Skip a task whose dependency failed — never build against a broken base.
123
+ const blockedBy = deps.filter((d) => failed.has(d) || !done.has(d));
124
+ if (blockedBy.length > 0) {
125
+ const outcome = {
126
+ taskId: task.id,
127
+ success: false,
128
+ summary: `skipped — unmet dependency: ${blockedBy.join(', ')}`,
129
+ turns: 0,
130
+ };
131
+ failed.add(task.id);
132
+ outcomes.push(outcome);
133
+ config.onTask?.(task, outcome);
134
+ continue;
135
+ }
136
+ let designLines = [];
137
+ if (config.retrieveDesign) {
138
+ try {
139
+ designLines = await config.retrieveDesign(task);
140
+ }
141
+ catch {
142
+ designLines = [];
143
+ }
144
+ }
145
+ const ctx = assembleTaskContext(task, config.mission, blackboard, config.maxDepSummaryChars, {
146
+ designLines,
147
+ budgetChars: budget,
148
+ });
149
+ const outcome = await config.runTask(ctx, task);
150
+ turns += outcome.turns;
151
+ outcomes.push(outcome);
152
+ if (outcome.success) {
153
+ done.add(task.id);
154
+ blackboard.set(task.id, outcome);
155
+ try {
156
+ await config.publish?.(outcome, task);
157
+ }
158
+ catch {
159
+ // publishing to durable memory is best-effort
160
+ }
161
+ // P5 — adaptive re-planning: fold discovered subtasks into the queue.
162
+ const fresh = (outcome.newTasks ?? []).filter((t) => !known.has(t.id));
163
+ if (fresh.length > 0 && queue.length + fresh.length <= maxTasks) {
164
+ for (const t of fresh)
165
+ known.add(t.id);
166
+ // Re-topo-sort the not-yet-run remainder plus the new tasks so deps hold.
167
+ const remainder = queue.slice(qi + 1).concat(fresh);
168
+ const resorted = topoOrder(remainder);
169
+ queue = queue.slice(0, qi + 1).concat(resorted);
170
+ }
171
+ }
172
+ else {
173
+ failed.add(task.id);
174
+ }
175
+ config.onTask?.(task, outcome);
176
+ }
177
+ return {
178
+ success: failed.size === 0,
179
+ completed: [...done],
180
+ failed: [...failed],
181
+ turns,
182
+ outcomes,
183
+ };
184
+ }
185
+ //# sourceMappingURL=task-orchestrator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"task-orchestrator.js","sourceRoot":"","sources":["../../src/delivery/task-orchestrator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAmF3C,MAAM,yBAAyB,GAAG,GAAG,CAAC;AACtC,6DAA6D;AAC7D,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAClC,MAAM,4BAA4B,GAAG,IAAI,CAAC;AAC1C,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAE7B;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,MAAc,EAAE,YAAoB,EAAE,MAAc;IAChF,IAAI,MAAM,CAAC,MAAM,IAAI,MAAM;QAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC;IAC/D,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACjC,sEAAsE;IACtE,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,KAAK,IAAI,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,MAAM,IAAI,OAAO,GAAG,YAAY,EAAE,CAAC,EAAE,EAAE,CAAC;QACzG,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAChC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACnB,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC;IACD,IAAI,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC3B,IAAI,OAAO,GAAG,CAAC;QAAE,GAAG,IAAI,+BAA+B,OAAO,sDAAsD,MAAM,eAAe,CAAC;IAC1I,sEAAsE;IACtE,IAAI,GAAG,CAAC,MAAM,GAAG,MAAM;QAAE,GAAG,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,GAAG,mCAAmC,CAAC;IAC1F,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC;AAC/C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAAsB,EACtB,OAAe,EACf,UAAoC,EACpC,kBAAkB,GAAG,yBAAyB,EAC9C,OAAyD,EAAE;IAE3D,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,yDAAyD;IACzD,QAAQ,CAAC,IAAI,CAAC,kCAAkC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,qBAAqB,CAAC,EAAE,CAAC,CAAC;IAC3F,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAClB,QAAQ,CAAC,IAAI,CAAC,eAAe,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC;IAC5C,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAEzB,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAClB,QAAQ,CAAC,IAAI,CAAC,wBAAwB,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IACpF,CAAC;IACD,IAAI,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC9C,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAClB,QAAQ,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;QACzC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;IACrE,CAAC;IAED,uEAAuE;IACvE,2CAA2C;IAC3C,IAAI,IAAI,CAAC,WAAW,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpD,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAClB,QAAQ,CAAC,IAAI,CAAC,sDAAsD,CAAC,CAAC;QACtE,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;IACvE,CAAC;IAED,0EAA0E;IAC1E,6EAA6E;IAC7E,2EAA2E;IAC3E,MAAM,YAAY,GAAa,EAAE,CAAC;IAClC,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAChE,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpB,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAClB,QAAQ,CAAC,IAAI,CAAC,0DAA0D,CAAC,CAAC;QAC1E,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;YACzB,MAAM,GAAG,GAAG,UAAU,CAAC,GAAG,CAAC,KAAK,CAAE,CAAC;YACnC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACzB,MAAM,IAAI,GAAG,CAAC,GAAG,CAAC,QAAQ,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC;YACxE,QAAQ,CAAC,IAAI,CAAC,KAAK,KAAK,KAAK,IAAI,EAAE,CAAC,CAAC;QACvC,CAAC;IACH,CAAC;IAED,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChC,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,EAAE,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,WAAW,IAAI,4BAA4B,CAAC,CAAC;IAC3G,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC;AACpE,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAA0B;IAC1D,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,iBAAiB,CAAC;IACtD,MAAM,MAAM,GAAG,MAAM,CAAC,kBAAkB,CAAC;IACzC,4EAA4E;IAC5E,yCAAyC;IACzC,IAAI,KAAK,GAAG,SAAS,CAAC,MAAM,CAAC,KAAwB,CAAuB,CAAC;IAC7E,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC9C,MAAM,UAAU,GAAG,IAAI,GAAG,EAAuB,CAAC;IAClD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,MAAM,GAAG,IAAI,GAAG,EAAU,CAAC;IACjC,MAAM,QAAQ,GAAkB,EAAE,CAAC;IACnC,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,KAAK,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,GAAG,KAAK,CAAC,MAAM,EAAE,EAAE,EAAE,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,KAAK,CAAC,EAAE,CAAC,CAAC;QACvB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;QAC7B,2EAA2E;QAC3E,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACpE,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACzB,MAAM,OAAO,GAAgB;gBAC3B,MAAM,EAAE,IAAI,CAAC,EAAE;gBACf,OAAO,EAAE,KAAK;gBACd,OAAO,EAAE,+BAA+B,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;gBAC9D,KAAK,EAAE,CAAC;aACT,CAAC;YACF,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACpB,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACvB,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;YAC/B,SAAS;QACX,CAAC;QAED,IAAI,WAAW,GAAa,EAAE,CAAC;QAC/B,IAAI,MAAM,CAAC,cAAc,EAAE,CAAC;YAC1B,IAAI,CAAC;gBACH,WAAW,GAAG,MAAM,MAAM,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;YAClD,CAAC;YAAC,MAAM,CAAC;gBACP,WAAW,GAAG,EAAE,CAAC;YACnB,CAAC;QACH,CAAC;QACD,MAAM,GAAG,GAAG,mBAAmB,CAAC,IAAI,EAAE,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,MAAM,CAAC,kBAAkB,EAAE;YAC3F,WAAW;YACX,WAAW,EAAE,MAAM;SACpB,CAAC,CAAC;QACH,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QAChD,KAAK,IAAI,OAAO,CAAC,KAAK,CAAC;QACvB,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACvB,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YACpB,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAClB,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;YACjC,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;YACxC,CAAC;YAAC,MAAM,CAAC;gBACP,8CAA8C;YAChD,CAAC;YACD,sEAAsE;YACtE,MAAM,KAAK,GAAG,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;YACvE,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,IAAI,QAAQ,EAAE,CAAC;gBAChE,KAAK,MAAM,CAAC,IAAI,KAAK;oBAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;gBACvC,0EAA0E;gBAC1E,MAAM,SAAS,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACpD,MAAM,QAAQ,GAAG,SAAS,CAAC,SAA4B,CAAuB,CAAC;gBAC/E,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YAClD,CAAC;QACH,CAAC;aAAM,CAAC;YACN,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACtB,CAAC;QACD,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IACjC,CAAC;IAED,OAAO;QACL,OAAO,EAAE,MAAM,CAAC,IAAI,KAAK,CAAC;QAC1B,SAAS,EAAE,CAAC,GAAG,IAAI,CAAC;QACpB,MAAM,EAAE,CAAC,GAAG,MAAM,CAAC;QACnB,KAAK;QACL,QAAQ;KACT,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.100.0",
3
+ "version": "1.101.1",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -205,6 +205,32 @@ PROXY_FORCED_HARD_RELEASE = int(os.environ.get("PROXY_FORCED_HARD_RELEASE", "30"
205
205
  PROXY_CONTEXT_RELEASE_THRESHOLD = float(
206
206
  os.environ.get("PROXY_CONTEXT_RELEASE_THRESHOLD", "0.90")
207
207
  )
208
+ # ---------------------------------------------------------------------------
209
+ # STUCK-BREAK guardrail: a small model can recognize it is looping ("I've been
210
+ # stuck in a loop, let me break out") yet keep repeating the SAME failing tool
211
+ # call -- meta-cognition without an exit. Two signals, both observed live on a
212
+ # qwen3.6 session that looped ~18min fetching a rate-limited GitHub API:
213
+ # (a) repeated self-reported "stuck" assistant text, and
214
+ # (b) repeated tool calls hitting a known rate-limited API host.
215
+ # When either streak crosses its threshold the proxy forces a TERMINAL turn:
216
+ # tool_choice back to auto + a firm directive to stop retrying and either
217
+ # proceed without the unreachable resource or ask the operator. Default on;
218
+ # PROXY_STUCK_BREAK=off to disable.
219
+ PROXY_STUCK_BREAK = os.environ.get("PROXY_STUCK_BREAK", "on").lower() not in {
220
+ "0", "false", "off", "no",
221
+ }
222
+ # Self-reported-stuck phrases (lowercased match). Deliberately narrow.
223
+ _STUCK_PHRASE_RE = re.compile(
224
+ r"stuck in a loop|been stuck|break out of (?:this|the) loop|going in circles|"
225
+ r"repeating myself|same (?:thing|error) (?:again|repeatedly)",
226
+ re.IGNORECASE,
227
+ )
228
+ # Tool args reaching into a rate-limited REST API host (the wrong channel; the
229
+ # hint steers to the browser tool / git clone, which are not rate-limited).
230
+ _RATE_LIMITED_API_RE = re.compile(r"api\.github\.com", re.IGNORECASE)
231
+ PROXY_STUCK_TEXT_THRESHOLD = int(os.environ.get("PROXY_STUCK_TEXT_THRESHOLD", "2"))
232
+ PROXY_STUCK_API_THRESHOLD = int(os.environ.get("PROXY_STUCK_API_THRESHOLD", "3"))
233
+
208
234
  PROXY_TOOL_STATE_MACHINE = os.environ.get(
209
235
  "PROXY_TOOL_STATE_MACHINE", "on"
210
236
  ).lower() not in {
@@ -1128,6 +1154,9 @@ class SessionMonitor:
1128
1154
  recon_hard_fires: int = 0 # Fix E: monotonic count of recon hard-tier firings
1129
1155
  catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
1130
1156
  unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
1157
+ self_stuck_streak: int = 0 # consecutive assistant texts self-reporting a loop
1158
+ rate_limited_api_streak: int = 0 # consecutive tool calls hitting a rate-limited API host
1159
+ stuck_break_fires: int = 0 # monotonic count of forced stuck-breaks
1131
1160
  tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
1132
1161
  malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
1133
1162
  invalid_tool_call_streak: int = 0 # consecutive invalid tool arg payloads
@@ -1363,6 +1392,40 @@ class SessionMonitor:
1363
1392
  by_tool = self.tool_target_history.setdefault(name, {})
1364
1393
  by_tool[target] = by_tool.get(target, 0) + 1
1365
1394
 
1395
+ def note_assistant_text(self, text: str) -> None:
1396
+ """Track the model self-reporting that it is stuck (STUCK-BREAK signal
1397
+ (a)). A matching turn increments the streak; a non-matching turn resets
1398
+ it, so only SUSTAINED self-reported looping trips the break."""
1399
+ if not PROXY_STUCK_BREAK or not text:
1400
+ self.self_stuck_streak = 0
1401
+ return
1402
+ if _STUCK_PHRASE_RE.search(text):
1403
+ self.self_stuck_streak += 1
1404
+ else:
1405
+ self.self_stuck_streak = 0
1406
+
1407
+ def note_tool_arg_hosts(self, arg_blobs: list) -> None:
1408
+ """Track repeated tool calls into a rate-limited API host (STUCK-BREAK
1409
+ signal (b)). Reset when a turn uses none, so only a sustained wrong-
1410
+ channel loop trips the hint."""
1411
+ if not PROXY_STUCK_BREAK:
1412
+ return
1413
+ blob = " ".join(a for a in arg_blobs if isinstance(a, str))
1414
+ if _RATE_LIMITED_API_RE.search(blob):
1415
+ self.rate_limited_api_streak += 1
1416
+ else:
1417
+ self.rate_limited_api_streak = 0
1418
+
1419
+ def should_force_stuck_break(self) -> tuple[bool, str]:
1420
+ """True + reason when a terminal break should be forced this turn."""
1421
+ if not PROXY_STUCK_BREAK:
1422
+ return False, ""
1423
+ if self.self_stuck_streak >= PROXY_STUCK_TEXT_THRESHOLD:
1424
+ return True, f"self-reported stuck x{self.self_stuck_streak}"
1425
+ if self.rate_limited_api_streak >= PROXY_STUCK_API_THRESHOLD:
1426
+ return True, f"rate-limited-API retries x{self.rate_limited_api_streak}"
1427
+ return False, ""
1428
+
1366
1429
  def has_duplicate_read_target(self, threshold: int = 2) -> tuple[bool, str]:
1367
1430
  """Check if any read-only tool has re-read the same target >= threshold times.
1368
1431
 
@@ -4067,6 +4130,36 @@ def _writes_are_gated(openai_body: dict) -> bool:
4067
4130
  return False
4068
4131
 
4069
4132
 
4133
+ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> None:
4134
+ """Force a terminal turn when the model is looping self-awarely or hammering
4135
+ a rate-limited API. Unlike the cycle-breaker (which narrows tools), this
4136
+ STOPS tool coercion and tells the model to synthesize / ask / route around
4137
+ the unreachable resource -- converting the model's own "I'm stuck" into an
4138
+ actual exit. Fires at most escalating; monotonic counter for telemetry."""
4139
+ should, reason = monitor.should_force_stuck_break()
4140
+ if not should:
4141
+ return
4142
+ monitor.stuck_break_fires += 1
4143
+ # Release the tool-choice coercion so a plain text turn is allowed.
4144
+ if openai_body.get("tool_choice") == "required":
4145
+ openai_body["tool_choice"] = "auto"
4146
+ directive = (
4147
+ "\n\nSTOP — you are repeating a failing action (" + reason + "). Do NOT "
4148
+ "retry the same tool or fetch again. If a resource is unreachable (e.g. a "
4149
+ "rate-limited GitHub REST API), switch channel: use the browser tool or "
4150
+ "`git clone` (git protocol), NOT api.github.com. If it is still "
4151
+ "unavailable, proceed WITHOUT it using what you already have, or ask the "
4152
+ "operator the single blocking question in one sentence. Take a DIFFERENT "
4153
+ "action now."
4154
+ )
4155
+ msgs = openai_body.get("messages") or []
4156
+ if msgs and msgs[0].get("role") == "system":
4157
+ msgs[0]["content"] = (msgs[0].get("content") or "") + directive
4158
+ else:
4159
+ msgs.insert(0, {"role": "system", "content": directive.strip()})
4160
+ logger.warning("STUCK-BREAK: forced terminal turn (%s, fires=%d)", reason, monitor.stuck_break_fires)
4161
+
4162
+
4070
4163
  def _maybe_inject_recon_convergence(
4071
4164
  openai_body: dict,
4072
4165
  monitor: "SessionMonitor",
@@ -4846,6 +4939,8 @@ def build_openai_request(
4846
4939
  # pre-narrowing toolset so it can restore a dropped write tool.
4847
4940
  _maybe_inject_recon_convergence(openai_body, monitor, full_openai_tools)
4848
4941
 
4942
+ _maybe_inject_stuck_break(openai_body, monitor)
4943
+
4849
4944
  _apply_thinking_grammar(openai_body)
4850
4945
 
4851
4946
  _apply_json_response_grammar(openai_body, anthropic_body)
@@ -8612,6 +8707,12 @@ async def stream_anthropic_response(
8612
8707
  tc_names,
8613
8708
  [a[:200] for a in tc_args],
8614
8709
  )
8710
+ # STUCK-BREAK signals: feed the assistant text + tool args to the monitor.
8711
+ try:
8712
+ monitor.note_assistant_text(accumulated_text)
8713
+ monitor.note_tool_arg_hosts(list(tc_args))
8714
+ except Exception:
8715
+ pass
8615
8716
 
8616
8717
  # -------------------------------------------------------------------
8617
8718
  # Post-stream: recover <tool_call> XML from accumulated text