@agjs/tsforge 0.2.6 → 0.2.8

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.
@@ -28,7 +28,10 @@ import {
28
28
  import { connectMcpServers } from "../mcp";
29
29
  import { loadAndRegisterPlugins } from "../config/external-plugins";
30
30
  import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
31
- import type { Reporter } from "./loop.types";
31
+ import type { Reporter, ILoopEvent } from "./loop.types";
32
+ import type { TtsrManager } from "./ttsr";
33
+ import { initTtsrManager, applyTtsrInterrupt } from "./ttsr-init";
34
+ import { mineLessons, consolidate as consolidateMemory } from "./memory";
32
35
  import { CHAT_SYSTEM, COMPACT_SYSTEM } from "./prompt";
33
36
  import {
34
37
  buildTsService,
@@ -396,6 +399,12 @@ export class Session {
396
399
  private readonly forceTools: boolean;
397
400
  /** Mid-session turn-cap override (setMaxTurns) — a web scaffold raises it. */
398
401
  private maxTurnsOverride?: number;
402
+ /** TTSR manager (built-in + project + memory-learned rules). Null when TTSR is
403
+ * disabled. Built in `create` (needs async rule loading). */
404
+ private ttsrManager: TtsrManager | null = null;
405
+ /** Events of the CURRENT send (reset each drive), buffered off ctx.report so the
406
+ * post-send memory hook can mine the run for failure→fix lessons. */
407
+ private readonly sendEvents: ILoopEvent[] = [];
399
408
 
400
409
  private constructor(cfg: ISessionConfig, ctx: ILoopCtx) {
401
410
  this.provider = cfg.provider;
@@ -443,9 +452,19 @@ export class Session {
443
452
  }
444
453
 
445
454
  this.ctx = ctx;
455
+ // Buffer events off ctx.report (where edit/create/validated flow) so the
456
+ // post-send memory hook can mine them; still forward to the original reporter.
457
+ const rawCtxReport = ctx.report;
458
+
459
+ this.ctx.report = (event) => {
460
+ this.sendEvents.push(event);
461
+ rawCtxReport(event);
462
+ };
463
+
446
464
  this.state = {
447
465
  prevGateErrors: [],
448
466
  gateNoProgress: 0,
467
+ errorAge: new Map(),
449
468
  lastGateCount: -1,
450
469
  edits: 0,
451
470
  regressions: 0,
@@ -522,7 +541,14 @@ export class Session {
522
541
  },
523
542
  };
524
543
 
525
- return new Session(cfg, ctx);
544
+ const session = new Session(cfg, ctx);
545
+
546
+ // Build the TTSR manager (built-in + project + memory-learned rules) so the
547
+ // interactive loop gets the SAME mid-stream guidance the headless loop does —
548
+ // including the failure→fix lessons learned in this repo.
549
+ session.ttsrManager = await initTtsrManager(cfg.cwd, report, SESSION_ID);
550
+
551
+ return session;
526
552
  }
527
553
 
528
554
  /** The current gate command (empty when none). */
@@ -698,8 +724,13 @@ export class Session {
698
724
  */
699
725
  async send(text: string, opts: ISendOptions = {}): Promise<ISendResult> {
700
726
  const { ctx, report } = this;
727
+ // Interactive ceiling is a RUNAWAY backstop, not the primary stop — the
728
+ // progress guards (samePersist / gateNoProgress) pull the agent out the moment
729
+ // it stops converging. Set high so normal long back-and-forth never trips it.
701
730
  const maxTurns =
702
- this.maxTurnsOverride ?? this.cfg.maxTurns ?? LOOP_LIMITS.maxTurns;
731
+ this.maxTurnsOverride ??
732
+ this.cfg.maxTurns ??
733
+ LOOP_LIMITS.interactiveBackstopTurns;
703
734
  const sendStart = performance.now();
704
735
 
705
736
  // Thread cancellation to the tool `run` commands and the gate (not just the
@@ -1018,6 +1049,9 @@ export class Session {
1018
1049
  mcpSchemas.length > 0 ? [...baseTools, ...mcpSchemas] : baseTools;
1019
1050
  const callStart = performance.now();
1020
1051
  let firstTokenAt = 0;
1052
+
1053
+ this.ttsrManager?.resetBuffer();
1054
+
1021
1055
  const res = await this.provider.complete(ctx.messages, {
1022
1056
  tools: offeredTools,
1023
1057
  temperature: this.cfg.temperature ?? 0,
@@ -1026,6 +1060,7 @@ export class Session {
1026
1060
  ...(this.cfg.thinkingTokenBudget === undefined
1027
1061
  ? {}
1028
1062
  : { thinkingTokenBudget: this.cfg.thinkingTokenBudget }),
1063
+ ...this.ttsrCallOption(),
1029
1064
  ...(signal === undefined ? {} : { signal }),
1030
1065
  onToken: (token, channel) => {
1031
1066
  // Stamp the first token so tokens/sec measures generation rate (excluding
@@ -1067,6 +1102,10 @@ export class Session {
1067
1102
 
1068
1103
  ctx.messages.push(assistantMessage(res));
1069
1104
 
1105
+ // Every model call advances TTSR cooldown accounting (including interrupted
1106
+ // ones, so repeatGap rules count correctly after a retry).
1107
+ this.ttsrManager?.incrementTurnCount();
1108
+
1070
1109
  if (res.salvaged !== undefined && res.salvaged > 0) {
1071
1110
  report({
1072
1111
  kind: "tool",
@@ -1364,10 +1403,103 @@ export class Session {
1364
1403
  }
1365
1404
  }
1366
1405
 
1406
+ /** Drive one send to a terminal result, then mine the send's events for
1407
+ * failure→fix lessons (best-effort, never affects the result). The buffer is
1408
+ * reset per send so each maps to one "run". */
1367
1409
  private async drive(
1368
1410
  maxTurns: number,
1369
1411
  sendStart: number,
1370
1412
  opts: ISendOptions
1413
+ ): Promise<ISendResult> {
1414
+ this.sendEvents.length = 0;
1415
+
1416
+ try {
1417
+ return await this.driveInner(maxTurns, sendStart, opts);
1418
+ } finally {
1419
+ await this.consolidateLessons();
1420
+ }
1421
+ }
1422
+
1423
+ /** Mine the current send's events into the project's learned-rules memory.
1424
+ * Gated on the TTSR flag (learned rules are recalled via TTSR). */
1425
+ private async consolidateLessons(): Promise<void> {
1426
+ if (!flags.ttsr()) {
1427
+ return;
1428
+ }
1429
+
1430
+ try {
1431
+ const candidates = mineLessons(this.sendEvents);
1432
+ const runId = `${SESSION_ID}-${Date.now().toString(36)}`;
1433
+ const active = await consolidateMemory(this.ctx.cwd, candidates, runId);
1434
+
1435
+ if (active > 0) {
1436
+ this.report({
1437
+ kind: "ttsr",
1438
+ task: SESSION_ID,
1439
+ message: `memory: ${String(active)} learned rule(s) active in .tsforge/learned-rules.json`,
1440
+ });
1441
+ }
1442
+ } catch {
1443
+ // Memory is supplementary — never let it break a send.
1444
+ }
1445
+ }
1446
+
1447
+ /** The `ttsrManager` completion option, or nothing when TTSR is off. */
1448
+ private ttsrCallOption():
1449
+ | { ttsrManager: TtsrManager }
1450
+ | Record<string, never> {
1451
+ return this.ttsrManager === null ? {} : { ttsrManager: this.ttsrManager };
1452
+ }
1453
+
1454
+ /** Apply a mid-stream TTSR fire (inject guidance, retry). Returns true when it
1455
+ * fired (the caller should `continue`). */
1456
+ private handleTtsrFired(
1457
+ res: IModelResponse,
1458
+ turn: number,
1459
+ turnStart: number,
1460
+ sendStart: number
1461
+ ): boolean {
1462
+ if (res.ttsrFired === undefined) {
1463
+ return false;
1464
+ }
1465
+
1466
+ applyTtsrInterrupt(
1467
+ res.ttsrFired,
1468
+ this.state,
1469
+ this.ctx.messages,
1470
+ this.report,
1471
+ SESSION_ID,
1472
+ this.ttsrManager
1473
+ );
1474
+ emitTiming(this.report, SESSION_ID, turn, turnStart, sendStart);
1475
+
1476
+ return true;
1477
+ }
1478
+
1479
+ /** Handle a degenerate stream: a bounded recovery or a terminal stop. Returns a
1480
+ * stop result, "retry" to continue with a forced tool, or null if not degenerate. */
1481
+ private degenerationStop(
1482
+ res: IModelResponse,
1483
+ degenerations: number,
1484
+ turn: number,
1485
+ turnStart: number,
1486
+ sendStart: number
1487
+ ): ISendResult | "retry" | null {
1488
+ if (res.degenerated !== true) {
1489
+ return null;
1490
+ }
1491
+
1492
+ const stop = this.degenerationRecovery(degenerations, turn);
1493
+
1494
+ emitTiming(this.report, SESSION_ID, turn, turnStart, sendStart);
1495
+
1496
+ return stop ?? "retry";
1497
+ }
1498
+
1499
+ private async driveInner(
1500
+ maxTurns: number,
1501
+ sendStart: number,
1502
+ opts: ISendOptions
1371
1503
  ): Promise<ISendResult> {
1372
1504
  const { ctx, report } = this;
1373
1505
  // The gate confirms CHANGES, not answers: it fires only once the model has
@@ -1433,24 +1565,34 @@ export class Session {
1433
1565
 
1434
1566
  forceTool = false;
1435
1567
 
1436
- // The stream caught a degenerate repetition loop. Try a BOUNDED recovery
1437
- // (force a concrete tool call next turn can't loop in prose) before
1438
- // giving up; see degenerationRecovery.
1439
- if (res.degenerated === true) {
1440
- const stop = this.degenerationRecovery(degenerations, turn);
1441
-
1442
- emitTiming(report, SESSION_ID, turn, turnStart, sendStart);
1568
+ // A learned/built-in TTSR rule fired mid-stream inject its corrective
1569
+ // guidance and retry (checked before degeneration so the fix lands first).
1570
+ // This is how memory's failure→fix lessons reach an interactive session.
1571
+ if (this.handleTtsrFired(res, turn, turnStart, sendStart)) {
1572
+ continue;
1573
+ }
1443
1574
 
1444
- if (stop !== null) {
1445
- return stop;
1446
- }
1575
+ // The stream caught a degenerate repetition loop. Bounded recovery (force a
1576
+ // concrete tool call next turn) before giving up; see degenerationRecovery.
1577
+ const deg = this.degenerationStop(
1578
+ res,
1579
+ degenerations,
1580
+ turn,
1581
+ turnStart,
1582
+ sendStart
1583
+ );
1447
1584
 
1585
+ if (deg === "retry") {
1448
1586
  degenerations += 1;
1449
1587
  forceTool = true;
1450
1588
 
1451
1589
  continue;
1452
1590
  }
1453
1591
 
1592
+ if (deg !== null) {
1593
+ return deg;
1594
+ }
1595
+
1454
1596
  // FORCED-TOOLS: a lone yield_status call becomes a normal stop.
1455
1597
  this.resolveYieldCalls(res);
1456
1598
 
@@ -1505,7 +1647,7 @@ export class Session {
1505
1647
  kind: "stuck",
1506
1648
  task: SESSION_ID,
1507
1649
  cycles: maxTurns,
1508
- message: `stuck (hit ${maxTurns}-turn cap)`,
1650
+ message: `stuck (hit the ${maxTurns}-turn runaway backstop — progress guards never tripped, which is unusual; re-steer or narrow the task)`,
1509
1651
  });
1510
1652
 
1511
1653
  return { status: "stuck", turns: maxTurns };
@@ -0,0 +1,111 @@
1
+ import { join } from "node:path";
2
+
3
+ import type { Reporter } from "./loop.types";
4
+ import type { ILoopState } from "./turn";
5
+ import type { IChatMessage } from "../inference";
6
+ import { flags } from "../config";
7
+ import { TtsrManager, parseProjectRules, type ITtsrRule } from "./ttsr";
8
+ import { DEFAULT_TTSR_RULES } from "./ttsr-defaults";
9
+
10
+ const TTSR_INTERRUPT_CAP = 3;
11
+
12
+ /**
13
+ * Load a project's TTSR rules: hand-authored `.tsforge/rules.json` AND the
14
+ * memory-learned `.tsforge/learned-rules.json` (the failure→fix lessons the
15
+ * harness wrote itself). Both are tolerated-if-missing. Learned rules are named
16
+ * `learned-*`, so they never collide with hand or built-in rules on dedup.
17
+ */
18
+ export async function loadProjectTtsrRules(cwd: string): Promise<ITtsrRule[]> {
19
+ const files = [
20
+ join(cwd, ".tsforge", "rules.json"),
21
+ join(cwd, ".tsforge", "learned-rules.json"),
22
+ ];
23
+ const rules: ITtsrRule[] = [];
24
+
25
+ for (const path of files) {
26
+ const file = Bun.file(path);
27
+
28
+ if (await file.exists()) {
29
+ rules.push(...parseProjectRules(await file.text()));
30
+ }
31
+ }
32
+
33
+ return rules;
34
+ }
35
+
36
+ /**
37
+ * Build the TTSR manager for a run: built-in defaults + project + learned rules.
38
+ * Shared by the headless loop (run.ts) and the interactive session (session.ts).
39
+ * Returns null when TTSR is disabled by flag.
40
+ */
41
+ export async function initTtsrManager(
42
+ cwd: string,
43
+ report: Reporter,
44
+ taskId: string
45
+ ): Promise<TtsrManager | null> {
46
+ if (!flags.ttsr()) {
47
+ return null;
48
+ }
49
+
50
+ const manager = new TtsrManager();
51
+
52
+ for (const rule of DEFAULT_TTSR_RULES) {
53
+ manager.addRule(rule);
54
+ }
55
+
56
+ let added = 0;
57
+
58
+ for (const rule of await loadProjectTtsrRules(cwd)) {
59
+ if (manager.addRule(rule)) {
60
+ added += 1;
61
+ }
62
+ }
63
+
64
+ if (added > 0) {
65
+ report({
66
+ kind: "ttsr",
67
+ task: taskId,
68
+ message: `loaded ${added} project/learned TTSR rule(s) from .tsforge/`,
69
+ });
70
+ }
71
+
72
+ return manager;
73
+ }
74
+
75
+ /**
76
+ * Apply a TTSR interrupt: count it, report it, inject the corrective guidance as
77
+ * a user message, and disable the manager once the per-run cap is hit (so a
78
+ * stubborn pattern can't loop forever). Shared by both loops; the caller decides
79
+ * what to do next (retry the turn). Timing emission stays with the caller.
80
+ */
81
+ export function applyTtsrInterrupt(
82
+ ttsrFired: { ruleName: string; guidance: string },
83
+ state: ILoopState,
84
+ messages: IChatMessage[],
85
+ report: Reporter,
86
+ taskId: string,
87
+ ttsrManager: TtsrManager | null
88
+ ): void {
89
+ state.ttsrInterrupts += 1;
90
+
91
+ report({
92
+ kind: "ttsr",
93
+ task: taskId,
94
+ message: `⚠ TTSR interrupted: ${ttsrFired.ruleName}`,
95
+ });
96
+
97
+ if (state.ttsrInterrupts >= TTSR_INTERRUPT_CAP) {
98
+ report({
99
+ kind: "tool",
100
+ task: taskId,
101
+ message: `TTSR disabled after ${state.ttsrInterrupts} interrupts (hit cap)`,
102
+ });
103
+
104
+ ttsrManager?.disable();
105
+ }
106
+
107
+ messages.push({
108
+ role: "user",
109
+ content: `⚠ generation interrupted: ${ttsrFired.guidance} Rewrite the affected part without that pattern.`,
110
+ });
111
+ }
package/src/loop/turn.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  sameErrorSet,
9
9
  type ErrorParser,
10
10
  type ErrorSet,
11
+ type IErrorItem,
11
12
  } from "../validate";
12
13
  import { isInScope } from "../lib/scope";
13
14
  import { fileExists, resolveScopeFiles } from "../lib/fs";
@@ -126,6 +127,9 @@ export interface ILoopCtx {
126
127
  export interface ILoopState {
127
128
  prevGateErrors: ErrorSet;
128
129
  gateNoProgress: number;
130
+ /** Per-error-key (file:rule) survival count: how many consecutive gate cycles
131
+ * each error has persisted. Drives the primary `samePersist` no-progress stop. */
132
+ errorAge: Map<string, number>;
129
133
  lastGateCount: number;
130
134
  edits: number;
131
135
  regressions: number;
@@ -688,6 +692,46 @@ function autoFixNotice(files: string[]): string {
688
692
  );
689
693
  }
690
694
 
695
+ /**
696
+ * Advance each error's per-(file:rule) survival count and return the first error
697
+ * that has now persisted for `samePersist` consecutive gate cycles — the model
698
+ * keeps failing at the SAME thing — or null. Rebuilds the map from the CURRENT
699
+ * keys, so a fixed error's age drops out (no stale growth) and an error that
700
+ * comes back later starts fresh. Catches "stuck on X" even while OTHER errors
701
+ * churn around it (which the whole-set `gateNoProgress` guard misses).
702
+ */
703
+ export function trackErrorAges(
704
+ state: ILoopState,
705
+ gateErrors: ErrorSet
706
+ ): IErrorItem | null {
707
+ const next = new Map<string, number>();
708
+ let stuck: IErrorItem | null = null;
709
+
710
+ for (const e of gateErrors) {
711
+ const age = (state.errorAge.get(e.key) ?? 0) + 1;
712
+
713
+ next.set(e.key, age);
714
+
715
+ if (age >= LOOP_LIMITS.samePersist && stuck === null) {
716
+ stuck = e;
717
+ }
718
+ }
719
+
720
+ state.errorAge = next;
721
+
722
+ return stuck;
723
+ }
724
+
725
+ /** The blocker diagnosis surfaced when a single error persists too long — names
726
+ * the rule + file + attempt count + the last message, so an interactive session
727
+ * hands back something the user can act on. */
728
+ export function persistDetail(e: IErrorItem): string {
729
+ const where = e.file !== undefined ? ` in ${e.file}` : "";
730
+ const rule = e.rule ?? "the same error";
731
+
732
+ return `stuck on ${rule}${where} after ${String(LOOP_LIMITS.samePersist)} attempts (last: ${e.message.slice(0, 140)})`;
733
+ }
734
+
691
735
  /**
692
736
  * The deterministic gate — the only authority on "done". Auto-fix, run the
693
737
  * optional fix command, validate, and return a terminal result (done/stuck) or
@@ -817,17 +861,47 @@ export async function settleGate(
817
861
  };
818
862
  }
819
863
 
864
+ // PRIMARY no-progress stop: the model keeps failing at the SAME (file,rule)
865
+ // for `samePersist` cycles running — even if other errors churn. Hand back a
866
+ // concrete blocker rather than spinning to a raw turn cap.
867
+ const persisted = trackErrorAges(state, gateErrors);
868
+
869
+ if (persisted !== null) {
870
+ const detail = persistDetail(persisted);
871
+
872
+ report({
873
+ kind: "stuck",
874
+ task: task.id,
875
+ cycles: turn,
876
+ detail,
877
+ message: `task ${task.id}: ${detail}`,
878
+ });
879
+
880
+ return {
881
+ task: task.id,
882
+ redConfirmed: true,
883
+ status: RUN_STATUS.stuck,
884
+ cycles: turn,
885
+ reason: STUCK_REASON.stalled,
886
+ detail,
887
+ };
888
+ }
889
+
890
+ // Coarser secondary net: the WHOLE error set unchanged this many cycles.
820
891
  state.gateNoProgress = sameErrorSet(state.prevGateErrors, gateErrors)
821
892
  ? state.gateNoProgress + 1
822
893
  : 0;
823
894
  state.prevGateErrors = gateErrors;
824
895
 
825
896
  if (state.gateNoProgress >= LOOP_LIMITS.gateStuckRepeats) {
897
+ const detail = `gate unchanged ${String(LOOP_LIMITS.gateStuckRepeats)} cycles (${String(gateErrors.length)} error(s) not converging)`;
898
+
826
899
  report({
827
900
  kind: "stuck",
828
901
  task: task.id,
829
902
  cycles: turn,
830
- message: `task ${task.id}: stuck (gate unchanged ${LOOP_LIMITS.gateStuckRepeats}x)`,
903
+ detail,
904
+ message: `task ${task.id}: stuck — ${detail}`,
831
905
  });
832
906
 
833
907
  return {
@@ -836,6 +910,7 @@ export async function settleGate(
836
910
  status: RUN_STATUS.stuck,
837
911
  cycles: turn,
838
912
  reason: STUCK_REASON.stalled,
913
+ detail,
839
914
  };
840
915
  }
841
916