@loops-adk/core 0.2.0 → 0.3.0

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.
@@ -395,6 +395,118 @@ declare function commitJob(config: CommitJobConfig): Job;
395
395
  /** A deterministic step from a plain function — for glue, checks, side effects. */
396
396
  declare function fnJob(label: string, fn: (ctx: JobContext) => Outcome | Promise<Outcome>): Job;
397
397
 
398
+ /**
399
+ * No-progress (stall) detection — the third hard stop, alongside `max` and
400
+ * `budget`. `max` bounds how many attempts a loop gets and `budget` bounds what
401
+ * they cost; neither can tell "slow but real convergence" from "the same failure
402
+ * five turns running". This module supplies that sensor, so a doomed loop exits
403
+ * at iteration N+window instead of burning everything it was given.
404
+ *
405
+ * The decision rule is NOVELTY, not change. An iteration makes progress when it
406
+ * reaches a state this run has never seen:
407
+ *
408
+ * - the workspace fingerprint (HEAD + pending diff + untracked content) is new
409
+ * — so an agent oscillating A→B→A gets no credit for the return trip;
410
+ * - a caller-supplied `signal` value is new — the escape hatch for loops whose
411
+ * progress lives outside the worktree (a queue length, a passing-test count);
412
+ * - the gate confidence beats its previous best by `minConfidenceDelta` — a
413
+ * high-water mark, so judge jitter around a flat score is not progress but
414
+ * slow, steady improvement accumulates until it clears the bar.
415
+ *
416
+ * `window` consecutive iterations with evidence and no novelty = stalled. The
417
+ * default is deliberately conservative (any channel's novelty counts): a false
418
+ * "stalled" on work that was actually converging is worse than one more
419
+ * iteration. An iteration with NO evidence channel at all (no git workspace, no
420
+ * confidence, no signal) is indeterminate — it neither extends nor resets the
421
+ * stall run, and the detector reports itself inert so the loop can warn once.
422
+ * Gate/review reasons are deliberately NOT compared: judge prose varies between
423
+ * identical verdicts, so it is quoted in the report but never used as evidence.
424
+ */
425
+
426
+ interface NoProgressConfig {
427
+ /** Consecutive no-progress iterations before the loop stalls out. Default 3. */
428
+ window?: number;
429
+ /**
430
+ * How far the gate confidence must beat its previous best to count as
431
+ * progress (the high-water mark). Default 0.02.
432
+ */
433
+ minConfidenceDelta?: number;
434
+ /**
435
+ * A caller-supplied progress fingerprint for state the workspace cannot see
436
+ * (a queue length, a passing-test count, an external resource). Returning a
437
+ * value this run has already produced counts as no progress; `undefined`
438
+ * leaves the channel out of this iteration's evidence. A throw is a bug in
439
+ * the definition and fails the loop, like any other guarded user code.
440
+ */
441
+ signal?: (ctx: JobContext, last: Outcome | undefined) => string | number | undefined | Promise<string | number | undefined>;
442
+ /**
443
+ * Read the workspace fingerprint each iteration (a few git subprocesses).
444
+ * Default true; set false when a custom `signal` is the only honest channel.
445
+ */
446
+ workspace?: boolean;
447
+ }
448
+ /** What `LoopConfig.noProgress` accepts: a bare window, or the full config. */
449
+ type NoProgressInput = number | NoProgressConfig;
450
+ /** The evidence a stalled loop carries out — on the outcome and the event. */
451
+ interface StallReport {
452
+ /** The configured window that was filled. */
453
+ window: number;
454
+ /** The consecutive no-progress iterations, in order. */
455
+ iterations: number[];
456
+ /** The last gate/review reason observed — what kept failing. */
457
+ reason: string;
458
+ /** Per-channel assessment of the tripping iteration. */
459
+ evidence: string[];
460
+ }
461
+ /** One completed, non-converged iteration as the tracker sees it. */
462
+ interface ProgressSample {
463
+ iteration: number;
464
+ /** Workspace fingerprint, when the workspace is a git repo. */
465
+ fingerprint?: string;
466
+ /** The confidence that gated this turn (review ?? until ?? body). */
467
+ confidence?: number;
468
+ /** The custom signal value, when a `signal` fn is configured. */
469
+ signal?: string;
470
+ /** The gate/review reason — reporting only, never evidence. */
471
+ reason?: string;
472
+ }
473
+ /** Resolve the `noProgress` sugar (`3` ⇒ `{ window: 3 }`) with defaults applied. */
474
+ declare function resolveNoProgress(input: NoProgressInput | undefined): Required<Pick<NoProgressConfig, 'window' | 'minConfidenceDelta'>> & NoProgressConfig | undefined;
475
+ /**
476
+ * The novelty tracker behind `LoopConfig.noProgress`. Feed it one sample per
477
+ * non-converged iteration; it returns a `StallReport` the moment `window`
478
+ * consecutive samples show evidence and no novelty.
479
+ */
480
+ declare class ProgressTracker {
481
+ readonly window: number;
482
+ readonly minConfidenceDelta: number;
483
+ /** Every state this run has reached, namespaced by channel. */
484
+ private readonly seen;
485
+ /** Confidence high-water mark — the best score at the last progress point. */
486
+ private best;
487
+ /** The current run of consecutive no-progress iterations. */
488
+ private stalledRun;
489
+ private lastEvidence;
490
+ private lastReason;
491
+ private indeterminate;
492
+ private sampled;
493
+ constructor(cfg: {
494
+ window: number;
495
+ minConfidenceDelta: number;
496
+ });
497
+ /**
498
+ * Record one iteration. Returns a `StallReport` when this sample fills the
499
+ * window, else undefined.
500
+ */
501
+ record(sample: ProgressSample): StallReport | undefined;
502
+ /**
503
+ * True when the detector has seen a full window of samples and none carried
504
+ * any evidence channel — detection is configured but cannot fire. The loop
505
+ * uses this to warn once instead of failing silently-inert.
506
+ */
507
+ isInert(): boolean;
508
+ }
509
+
398
510
  /**
399
511
  * The Environment provider — the third axis, after Engine (where the agent
400
512
  * thinks) and Workspace (where the code lives). Environment is where the code
@@ -595,6 +707,13 @@ interface Outcome {
595
707
  data?: unknown;
596
708
  /** Present when `status` is driven by a failure. */
597
709
  error?: LoopError;
710
+ /**
711
+ * Present when a loop ended `exhausted` because its `noProgress` detector
712
+ * tripped: the evidence that the last `window` iterations reached no state
713
+ * the run had not already seen. Lets a supervisor tell "stalled, re-brief it"
714
+ * from "ran out of runway mid-progress" without parsing the summary.
715
+ */
716
+ stall?: StallReport;
598
717
  /**
599
718
  * Structured feedback asking an earlier unit of work for another pass, and the
600
719
  * single channel for it. When `revision.target` is set, the enclosing `dag`
@@ -747,6 +866,17 @@ interface LoopConfig {
747
866
  stopOn?: ConditionInput;
748
867
  /** Iteration cap. Reached without passing => `exhausted`. */
749
868
  max?: number;
869
+ /**
870
+ * The third hard stop, alongside `max` and `budget`: end the loop `exhausted`
871
+ * when this many consecutive iterations make no observable progress — no
872
+ * workspace state the run has not already visited, no custom `signal` value
873
+ * not already seen, no gate confidence beating its previous best. A bare
874
+ * number is the window (`3` ⇒ three flat iterations); pass a `NoProgressConfig`
875
+ * for the full knobs. Off by default: a polling loop legitimately makes no
876
+ * progress until the outside world changes, so this is opt-in like `commit`.
877
+ * The stalled outcome carries the evidence as `Outcome.stall`.
878
+ */
879
+ noProgress?: NoProgressInput;
750
880
  /**
751
881
  * Runs when `until` is met. If it returns `pass`, the loop completes.
752
882
  * Any other status re-enters the loop — this is the "review fails, run the
@@ -881,6 +1011,12 @@ type LoopEvent = {
881
1011
  path: string[];
882
1012
  outcome: Outcome;
883
1013
  iterations: number;
1014
+ } | {
1015
+ kind: 'loop:stall';
1016
+ ts: number;
1017
+ path: string[];
1018
+ iteration: number;
1019
+ report: StallReport;
884
1020
  } | {
885
1021
  kind: 'limit:wait';
886
1022
  ts: number;
@@ -976,4 +1112,4 @@ type LoopEvent = {
976
1112
  code: string;
977
1113
  };
978
1114
 
979
- export { type PrInput as $, type AgentDef as A, type BudgetConfig as B, type ConditionInput as C, type DagConfig as D, type Environment as E, type FeedbackFinding as F, type GraphPosition as G, type CommitJobConfig as H, type ConditionResult as I, type Job as J, type DagNode as K, type LoopConfig as L, type EngineStreamEvent as M, type ForgeOpts as N, type Outcome as O, GhForge as P, type GroundConfig as Q, type RevisionRerun as R, type LogLevel as S, LoopError as T, type Usage as U, type LoopErrorCode as V, type Workspace as W, type MergeOptions as X, MockForge as Y, type MockForgeOptions as Z, type OutcomeStatus as _, type FeedbackDecision as a, type PrPatch as a0, type PrRef as a1, type RawPredicate as a2, type RetryPolicy as a3, SUBAGENT_TOOLS as a4, type Skill as a5, agentContract as a6, agentJob as a7, buildChecksArgs as a8, buildCreateArgs as a9, buildEditArgs as aa, buildMergeArgs as ab, buildViewArgs as ac, commitJob as ad, defineAgent as ae, defineSkill as af, fnJob as ag, fromFile as ah, isEngine as ai, isEnvironment as aj, isForge as ak, resolveSystem as al, type FeedbackSeverity as b, type FeedbackActionSeverity as c, type JobContext as d, type RevisionRequest as e, type JobMeta as f, type EngineRef as g, type Condition as h, type EngineOptions as i, type Engine as j, type EngineName as k, type AgentRequest as l, type EngineEventSink as m, type AgentResult as n, type EnvHandle as o, type LoopEvent as p, type Forge as q, type LimitPolicy as r, type AgentContractSummary as s, type AgentFailureMode as t, type AgentHumanGate as u, type AgentJobConfig as v, type AgentOutputContract as w, type AgentSkillRef as x, type AgentTier as y, Budget as z };
1115
+ export { type NoProgressInput as $, type AgentDef as A, type BudgetConfig as B, type ConditionInput as C, type DagConfig as D, type Environment as E, type FeedbackFinding as F, type GraphPosition as G, type CommitJobConfig as H, type ConditionResult as I, type Job as J, type DagNode as K, type LoopConfig as L, type EngineStreamEvent as M, type ForgeOpts as N, type Outcome as O, GhForge as P, type GroundConfig as Q, type RevisionRerun as R, type LogLevel as S, LoopError as T, type Usage as U, type LoopErrorCode as V, type Workspace as W, type MergeOptions as X, MockForge as Y, type MockForgeOptions as Z, type NoProgressConfig as _, type FeedbackDecision as a, type OutcomeStatus as a0, type PrInput as a1, type PrPatch as a2, type PrRef as a3, type ProgressSample as a4, ProgressTracker as a5, type RawPredicate as a6, type RetryPolicy as a7, SUBAGENT_TOOLS as a8, type Skill as a9, type StallReport as aa, agentContract as ab, agentJob as ac, buildChecksArgs as ad, buildCreateArgs as ae, buildEditArgs as af, buildMergeArgs as ag, buildViewArgs as ah, commitJob as ai, defineAgent as aj, defineSkill as ak, fnJob as al, fromFile as am, isEngine as an, isEnvironment as ao, isForge as ap, resolveNoProgress as aq, resolveSystem as ar, type FeedbackSeverity as b, type FeedbackActionSeverity as c, type JobContext as d, type RevisionRequest as e, type JobMeta as f, type EngineRef as g, type Condition as h, type EngineOptions as i, type Engine as j, type EngineName as k, type AgentRequest as l, type EngineEventSink as m, type AgentResult as n, type EnvHandle as o, type LoopEvent as p, type Forge as q, type LimitPolicy as r, type AgentContractSummary as s, type AgentFailureMode as t, type AgentHumanGate as u, type AgentJobConfig as v, type AgentOutputContract as w, type AgentSkillRef as x, type AgentTier as y, Budget as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loops-adk/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "author": "Jonny Neill",
6
6
  "description": "Run an agent in a convergence loop with an honest done-gate. A small, nestable loop and DAG primitive: deterministic plus agent-judge conditions, git as memory, review-restart, budgets, and a live TUI.",
@@ -74,6 +74,7 @@
74
74
  "bench:context:dry": "BENCH_DRY=1 BENCH_CB_GROUPS=bench/contextbench/groups.dry.json tsx bench/swecontextbench.ts",
75
75
  "bench:mechanism": "tsx bench/mechanism.ts",
76
76
  "example:poll": "tsx src/index.ts run examples/simple-poll.loop.ts --no-tui",
77
+ "example:stall": "tsx src/index.ts run examples/stall-demo.loop.ts --no-tui",
77
78
  "example:gate": "tsx src/index.ts run examples/confidence-gate.loop.ts",
78
79
  "prepack": "npm run build",
79
80
  "prepublishOnly": "npm run typecheck"