@twelvehart/orcats 0.0.0-seed.0 → 0.2.3
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/LICENSE +48 -0
- package/NOTICE +5 -0
- package/README.md +399 -0
- package/bin/orcats +4 -0
- package/dist/backends/acp-client.d.ts +74 -0
- package/dist/backends/acp-client.d.ts.map +1 -0
- package/dist/backends/acp-run.d.ts +31 -0
- package/dist/backends/acp-run.d.ts.map +1 -0
- package/dist/backends/claude-run.d.ts +35 -0
- package/dist/backends/claude-run.d.ts.map +1 -0
- package/dist/backends/claude-stream-json.d.ts +15 -0
- package/dist/backends/claude-stream-json.d.ts.map +1 -0
- package/dist/backends/codex-jsonl.d.ts +34 -0
- package/dist/backends/codex-jsonl.d.ts.map +1 -0
- package/dist/backends/codex-run.d.ts +40 -0
- package/dist/backends/codex-run.d.ts.map +1 -0
- package/dist/backends/codex.d.ts +5 -0
- package/dist/backends/codex.d.ts.map +1 -0
- package/dist/backends/conversation-config.d.ts +12 -0
- package/dist/backends/conversation-config.d.ts.map +1 -0
- package/dist/backends/index.d.ts +14 -0
- package/dist/backends/index.d.ts.map +1 -0
- package/dist/backends/opencode-run.d.ts +48 -0
- package/dist/backends/opencode-run.d.ts.map +1 -0
- package/dist/backends/opencode-sse.d.ts +35 -0
- package/dist/backends/opencode-sse.d.ts.map +1 -0
- package/dist/backends/pi-rpc.d.ts +21 -0
- package/dist/backends/pi-rpc.d.ts.map +1 -0
- package/dist/backends/pi-run.d.ts +21 -0
- package/dist/backends/pi-run.d.ts.map +1 -0
- package/dist/backends/select.d.ts +17 -0
- package/dist/backends/select.d.ts.map +1 -0
- package/dist/backends/subprocess-run.d.ts +68 -0
- package/dist/backends/subprocess-run.d.ts.map +1 -0
- package/dist/backends/types.d.ts +17 -0
- package/dist/backends/types.d.ts.map +1 -0
- package/dist/backends/unsupported.d.ts +9 -0
- package/dist/backends/unsupported.d.ts.map +1 -0
- package/dist/baseline/index.d.ts +56 -0
- package/dist/baseline/index.d.ts.map +1 -0
- package/dist/cli/args.d.ts +22 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/embedded.d.ts +7 -0
- package/dist/cli/embedded.d.ts.map +1 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/version.d.ts +2 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/conversation/ask-user.d.ts +17 -0
- package/dist/conversation/ask-user.d.ts.map +1 -0
- package/dist/conversation/conversation.d.ts +46 -0
- package/dist/conversation/conversation.d.ts.map +1 -0
- package/dist/conversation/harness.d.ts +14 -0
- package/dist/conversation/harness.d.ts.map +1 -0
- package/dist/conversation/index.d.ts +5 -0
- package/dist/conversation/index.d.ts.map +1 -0
- package/dist/conversation/queue.d.ts +15 -0
- package/dist/conversation/queue.d.ts.map +1 -0
- package/dist/flow/accessors.d.ts +13 -0
- package/dist/flow/accessors.d.ts.map +1 -0
- package/dist/flow/approvals.d.ts +6 -0
- package/dist/flow/approvals.d.ts.map +1 -0
- package/dist/flow/args.d.ts +4 -0
- package/dist/flow/args.d.ts.map +1 -0
- package/dist/flow/context.d.ts +36 -0
- package/dist/flow/context.d.ts.map +1 -0
- package/dist/flow/index.d.ts +5 -0
- package/dist/flow/index.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/loop/builder/index.d.ts +17 -0
- package/dist/loop/builder/index.d.ts.map +1 -0
- package/dist/loop/builder/types.d.ts +97 -0
- package/dist/loop/builder/types.d.ts.map +1 -0
- package/dist/loop/context/compaction.d.ts +38 -0
- package/dist/loop/context/compaction.d.ts.map +1 -0
- package/dist/loop/context/index.d.ts +4 -0
- package/dist/loop/context/index.d.ts.map +1 -0
- package/dist/loop/context/offload.d.ts +34 -0
- package/dist/loop/context/offload.d.ts.map +1 -0
- package/dist/loop/context/types.d.ts +13 -0
- package/dist/loop/context/types.d.ts.map +1 -0
- package/dist/loop/engine/bridge.d.ts +21 -0
- package/dist/loop/engine/bridge.d.ts.map +1 -0
- package/dist/loop/engine/index.d.ts +73 -0
- package/dist/loop/engine/index.d.ts.map +1 -0
- package/dist/loop/execution.d.ts +68 -0
- package/dist/loop/execution.d.ts.map +1 -0
- package/dist/loop/fanout.d.ts +148 -0
- package/dist/loop/fanout.d.ts.map +1 -0
- package/dist/loop/firing.d.ts +54 -0
- package/dist/loop/firing.d.ts.map +1 -0
- package/dist/loop/graph/analyze.d.ts +8 -0
- package/dist/loop/graph/analyze.d.ts.map +1 -0
- package/dist/loop/graph/index.d.ts +3 -0
- package/dist/loop/graph/index.d.ts.map +1 -0
- package/dist/loop/graph/types.d.ts +35 -0
- package/dist/loop/graph/types.d.ts.map +1 -0
- package/dist/loop/index.d.ts +7 -0
- package/dist/loop/index.d.ts.map +1 -0
- package/dist/loop/io/index.d.ts +4 -0
- package/dist/loop/io/index.d.ts.map +1 -0
- package/dist/loop/io/linear.d.ts +142 -0
- package/dist/loop/io/linear.d.ts.map +1 -0
- package/dist/loop/io/sink.d.ts +47 -0
- package/dist/loop/io/sink.d.ts.map +1 -0
- package/dist/loop/io/source.d.ts +64 -0
- package/dist/loop/io/source.d.ts.map +1 -0
- package/dist/loop/serve.d.ts +97 -0
- package/dist/loop/serve.d.ts.map +1 -0
- package/dist/loop/state/index.d.ts +5 -0
- package/dist/loop/state/index.d.ts.map +1 -0
- package/dist/loop/state/manifest.d.ts +36 -0
- package/dist/loop/state/manifest.d.ts.map +1 -0
- package/dist/loop/state/port.d.ts +33 -0
- package/dist/loop/state/port.d.ts.map +1 -0
- package/dist/loop/state/snapshot.d.ts +15 -0
- package/dist/loop/state/snapshot.d.ts.map +1 -0
- package/dist/loop/state/sqlite.d.ts +31 -0
- package/dist/loop/state/sqlite.d.ts.map +1 -0
- package/dist/loop/termination-contract.d.ts +28 -0
- package/dist/loop/termination-contract.d.ts.map +1 -0
- package/dist/loop/termination.d.ts +33 -0
- package/dist/loop/termination.d.ts.map +1 -0
- package/dist/model/backend-config.d.ts +30 -0
- package/dist/model/backend-config.d.ts.map +1 -0
- package/dist/model/brand.d.ts +9 -0
- package/dist/model/brand.d.ts.map +1 -0
- package/dist/model/errors.d.ts +11 -0
- package/dist/model/errors.d.ts.map +1 -0
- package/dist/model/index.d.ts +8 -0
- package/dist/model/index.d.ts.map +1 -0
- package/dist/model/json-schema.d.ts +4 -0
- package/dist/model/json-schema.d.ts.map +1 -0
- package/dist/model/result.d.ts +3 -0
- package/dist/model/result.d.ts.map +1 -0
- package/dist/model/schemas.d.ts +435 -0
- package/dist/model/schemas.d.ts.map +1 -0
- package/dist/model/structured.d.ts +9 -0
- package/dist/model/structured.d.ts.map +1 -0
- package/dist/monitor/index.d.ts +139 -0
- package/dist/monitor/index.d.ts.map +1 -0
- package/dist/plan/index.d.ts +3 -0
- package/dist/plan/index.d.ts.map +1 -0
- package/dist/plan/persistent.d.ts +23 -0
- package/dist/plan/persistent.d.ts.map +1 -0
- package/dist/plan/verdict.d.ts +9 -0
- package/dist/plan/verdict.d.ts.map +1 -0
- package/dist/review/index.d.ts +4 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/loop.d.ts +117 -0
- package/dist/review/loop.d.ts.map +1 -0
- package/dist/review/reviewers.d.ts +13 -0
- package/dist/review/reviewers.d.ts.map +1 -0
- package/dist/review/tool.d.ts +20 -0
- package/dist/review/tool.d.ts.map +1 -0
- package/dist/run-output/index.d.ts +112 -0
- package/dist/run-output/index.d.ts.map +1 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/typecheck.d.ts +23 -0
- package/dist/runner/typecheck.d.ts.map +1 -0
- package/dist/test-utils/fakes.d.ts +76 -0
- package/dist/test-utils/fakes.d.ts.map +1 -0
- package/dist/test-utils/fixtures.d.ts +4 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -0
- package/dist/test-utils/index.d.ts +4 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/tier2.d.ts +8 -0
- package/dist/test-utils/tier2.d.ts.map +1 -0
- package/dist/tools/fs.d.ts +11 -0
- package/dist/tools/fs.d.ts.map +1 -0
- package/dist/tools/git.d.ts +10 -0
- package/dist/tools/git.d.ts.map +1 -0
- package/dist/tools/github.d.ts +13 -0
- package/dist/tools/github.d.ts.map +1 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/linear.d.ts +117 -0
- package/dist/tools/linear.d.ts.map +1 -0
- package/dist/tools/process.d.ts +40 -0
- package/dist/tools/process.d.ts.map +1 -0
- package/dist/tools/terminal.d.ts +23 -0
- package/dist/tools/terminal.d.ts.map +1 -0
- package/package.json +89 -9
- package/src/backends/acp-client.ts +324 -0
- package/src/backends/acp-run.ts +636 -0
- package/src/backends/claude-run.ts +226 -0
- package/src/backends/claude-stream-json.ts +273 -0
- package/src/backends/codex-jsonl.ts +339 -0
- package/src/backends/codex-run.ts +177 -0
- package/src/backends/codex.ts +84 -0
- package/src/backends/conversation-config.ts +26 -0
- package/src/backends/index.ts +13 -0
- package/src/backends/opencode-run.ts +432 -0
- package/src/backends/opencode-sse.ts +360 -0
- package/src/backends/pi-rpc.ts +242 -0
- package/src/backends/pi-run.ts +154 -0
- package/src/backends/select.ts +77 -0
- package/src/backends/subprocess-run.ts +305 -0
- package/src/backends/types.ts +29 -0
- package/src/backends/unsupported.ts +48 -0
- package/src/baseline/index.ts +437 -0
- package/src/cli/args.ts +109 -0
- package/src/cli/embedded.ts +206 -0
- package/src/cli/main.ts +332 -0
- package/src/cli/version.ts +1 -0
- package/src/conversation/ask-user.ts +105 -0
- package/src/conversation/conversation.ts +109 -0
- package/src/conversation/harness.ts +39 -0
- package/src/conversation/index.ts +4 -0
- package/src/conversation/queue.ts +86 -0
- package/src/flow/accessors.ts +43 -0
- package/src/flow/approvals.ts +10 -0
- package/src/flow/args.ts +26 -0
- package/src/flow/context.ts +106 -0
- package/src/flow/index.ts +4 -0
- package/src/index.ts +17 -0
- package/src/loop/builder/index.ts +346 -0
- package/src/loop/builder/types.ts +131 -0
- package/src/loop/context/compaction.ts +175 -0
- package/src/loop/context/index.ts +3 -0
- package/src/loop/context/offload.ts +79 -0
- package/src/loop/context/types.ts +17 -0
- package/src/loop/engine/bridge.ts +61 -0
- package/src/loop/engine/index.ts +166 -0
- package/src/loop/execution.ts +453 -0
- package/src/loop/fanout.ts +444 -0
- package/src/loop/firing.ts +188 -0
- package/src/loop/graph/analyze.ts +128 -0
- package/src/loop/graph/index.ts +2 -0
- package/src/loop/graph/types.ts +45 -0
- package/src/loop/index.ts +11 -0
- package/src/loop/io/index.ts +3 -0
- package/src/loop/io/linear.ts +714 -0
- package/src/loop/io/sink.ts +170 -0
- package/src/loop/io/source.ts +255 -0
- package/src/loop/serve.ts +307 -0
- package/src/loop/state/index.ts +4 -0
- package/src/loop/state/manifest.ts +56 -0
- package/src/loop/state/port.ts +43 -0
- package/src/loop/state/snapshot.ts +129 -0
- package/src/loop/state/sqlite.ts +265 -0
- package/src/loop/termination-contract.ts +89 -0
- package/src/loop/termination.ts +156 -0
- package/src/model/backend-config.ts +34 -0
- package/src/model/brand.ts +11 -0
- package/src/model/errors.ts +28 -0
- package/src/model/index.ts +7 -0
- package/src/model/json-schema.ts +14 -0
- package/src/model/result.ts +28 -0
- package/src/model/schemas.ts +218 -0
- package/src/model/structured.ts +26 -0
- package/src/monitor/index.ts +367 -0
- package/src/plan/index.ts +2 -0
- package/src/plan/persistent.ts +98 -0
- package/src/plan/verdict.ts +3 -0
- package/src/review/index.ts +3 -0
- package/src/review/loop.ts +365 -0
- package/src/review/reviewers.ts +92 -0
- package/src/review/tool.ts +41 -0
- package/src/run-output/index.ts +319 -0
- package/src/runner/index.ts +1 -0
- package/src/runner/typecheck.ts +72 -0
- package/src/test-utils/fakes.ts +260 -0
- package/src/test-utils/fixtures.ts +33 -0
- package/src/test-utils/index.ts +3 -0
- package/src/test-utils/tier2.ts +12 -0
- package/src/tools/fs.ts +44 -0
- package/src/tools/git.ts +34 -0
- package/src/tools/github.ts +25 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/linear.ts +428 -0
- package/src/tools/process.ts +156 -0
- package/src/tools/terminal.ts +116 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { err, ok, type Result } from "neverthrow";
|
|
2
|
+
|
|
3
|
+
import type { AutonomousRequest, LlmBackend } from "../../backends/index.ts";
|
|
4
|
+
import type { Outcome } from "../../conversation/index.ts";
|
|
5
|
+
import { flow, llm, reporter as currentReporter } from "../../flow/index.ts";
|
|
6
|
+
import { backendFailed, type BackendTag, type RuntimeError, type Usage } from "../../model/index.ts";
|
|
7
|
+
import type { Observation } from "../context/index.ts";
|
|
8
|
+
import { executeLoop, type LoopExecutionAction, type LoopExecutionStepResult } from "../execution.ts";
|
|
9
|
+
import { analyzeGraph, type Edge } from "../graph/index.ts";
|
|
10
|
+
import { measure as manifestMeasure, type TaskManifest } from "../state/index.ts";
|
|
11
|
+
import { enforceTerminationContract, type TerminationContractError } from "../termination-contract.ts";
|
|
12
|
+
import type {
|
|
13
|
+
ConfidenceState,
|
|
14
|
+
GatesState,
|
|
15
|
+
LoopBuilder,
|
|
16
|
+
LoopGuards,
|
|
17
|
+
LoopOutcome,
|
|
18
|
+
LoopRunError,
|
|
19
|
+
LoopRunOptions,
|
|
20
|
+
LoopVariant,
|
|
21
|
+
TerminationPreset,
|
|
22
|
+
} from "./types.ts";
|
|
23
|
+
|
|
24
|
+
export * from "./types.ts";
|
|
25
|
+
|
|
26
|
+
// The loop() builder lowers a declarative loop onto flow() + loop execution
|
|
27
|
+
// (spec loop-builder / flow-runtime, design D1+D3). Effect lives in src/loop/engine;
|
|
28
|
+
// this surface never touches it, so the single-cycle case reads like a guarded `while`.
|
|
29
|
+
|
|
30
|
+
/** One body node: a deterministic `.step()` transform or the single `.reason()` LLM verb. */
|
|
31
|
+
interface StepItem<S> {
|
|
32
|
+
readonly kind: "step";
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly body: (state: S) => Promise<S> | S;
|
|
35
|
+
}
|
|
36
|
+
interface ReasonItem {
|
|
37
|
+
readonly kind: "reason";
|
|
38
|
+
readonly tag: BackendTag;
|
|
39
|
+
/** Resolved lazily so `llm()` reads the flow context established by `.run()`. */
|
|
40
|
+
readonly run: () => Promise<Outcome>;
|
|
41
|
+
}
|
|
42
|
+
type BodyItem<S> = StepItem<S> | ReasonItem;
|
|
43
|
+
|
|
44
|
+
interface Probe<S> {
|
|
45
|
+
readonly state: S;
|
|
46
|
+
readonly measure: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BodyResult<S> {
|
|
50
|
+
readonly state: S;
|
|
51
|
+
readonly usage?: Usage;
|
|
52
|
+
readonly observations: readonly Observation[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A constant cycle action: the fingerprint guard is disabled, so its identity never matters. */
|
|
56
|
+
const CYCLE_ACTION: LoopExecutionAction = { identity: "loop-cycle", inputs: null };
|
|
57
|
+
|
|
58
|
+
class LoopBuilderImpl<S> implements LoopBuilder<S> {
|
|
59
|
+
private readonly items: BodyItem<S>[] = [];
|
|
60
|
+
private measureFn: ((state: S) => Promise<number> | number) | undefined;
|
|
61
|
+
private termination: TerminationPreset<S> | LoopVariant<S> | undefined;
|
|
62
|
+
private readonly guards: { maxIterations?: number; wallClockMs?: number; tokenBudget?: number } = {};
|
|
63
|
+
|
|
64
|
+
constructor(private readonly name: string) {}
|
|
65
|
+
|
|
66
|
+
reason<B extends BackendTag, Output = unknown>(
|
|
67
|
+
backend: LlmBackend<B>,
|
|
68
|
+
request: AutonomousRequest<Output, B>
|
|
69
|
+
): LoopBuilder<S> {
|
|
70
|
+
this.items.push({
|
|
71
|
+
kind: "reason",
|
|
72
|
+
tag: backend.tag,
|
|
73
|
+
run: () => llm().autonomous(backend, request).awaitResult(),
|
|
74
|
+
});
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
step(name: string, body: (state: S) => Promise<S> | S): LoopBuilder<S> {
|
|
79
|
+
this.items.push({ kind: "step", name, body });
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
measure(fn: (state: S) => Promise<number> | number): LoopBuilder<S> {
|
|
84
|
+
this.measureFn = fn;
|
|
85
|
+
return this;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
until(termination: TerminationPreset<S> | LoopVariant<S>): LoopBuilder<S> {
|
|
89
|
+
this.termination = termination;
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
guard(opts: LoopGuards): LoopBuilder<S> {
|
|
94
|
+
if (opts.maxIterations !== undefined) this.guards.maxIterations = opts.maxIterations;
|
|
95
|
+
if (opts.wallClockMs !== undefined) this.guards.wallClockMs = opts.wallClockMs;
|
|
96
|
+
if (opts.tokenBudget !== undefined) this.guards.tokenBudget = opts.tokenBudget;
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async run(initial?: S, options: LoopRunOptions = {}): Promise<Result<LoopOutcome<S>, LoopRunError>> {
|
|
101
|
+
// 1. Termination by construction (design D3): reject an unguarded back-edge before running.
|
|
102
|
+
const contract = this.enforceContract();
|
|
103
|
+
if (contract.isErr()) {
|
|
104
|
+
return err(contract.error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const variant = this.resolveVariant();
|
|
108
|
+
const guards = this.resolveGuards();
|
|
109
|
+
|
|
110
|
+
// 2. Lower onto flow() + loop execution; accessors/test overrides resolve from this context.
|
|
111
|
+
return await flow<Result<LoopOutcome<S>, LoopRunError>>(options.args ?? [], options.overrides ?? {})(async () => {
|
|
112
|
+
let current = initial as S;
|
|
113
|
+
let cycle = 0;
|
|
114
|
+
|
|
115
|
+
const summary = await executeLoop<Probe<S>>({
|
|
116
|
+
evaluate: async () => ok({ state: current, measure: await variant(current, cycle) }),
|
|
117
|
+
converged: (probe) => probe.measure <= 0,
|
|
118
|
+
nextAction: (probe) => (probe.measure <= 0 ? undefined : CYCLE_ACTION),
|
|
119
|
+
execute: async () => {
|
|
120
|
+
const body = await this.runBody(current);
|
|
121
|
+
if (body.isErr()) {
|
|
122
|
+
return err(body.error);
|
|
123
|
+
}
|
|
124
|
+
current = body.value.state;
|
|
125
|
+
cycle += 1;
|
|
126
|
+
// Post-cycle measure from the SAME variant the loop converges on, so the emitted
|
|
127
|
+
// progress stream cannot drift from the termination variant (spec execution-observability).
|
|
128
|
+
const measure = await variant(current, cycle);
|
|
129
|
+
const stepResult: LoopExecutionStepResult = {
|
|
130
|
+
...(body.value.usage === undefined ? {} : { usage: body.value.usage }),
|
|
131
|
+
observations: body.value.observations,
|
|
132
|
+
measure,
|
|
133
|
+
...(measure <= 0 ? { stopReason: "converged" } : {}),
|
|
134
|
+
};
|
|
135
|
+
return ok(stepResult);
|
|
136
|
+
},
|
|
137
|
+
fingerprint: false,
|
|
138
|
+
maxIterations: guards.maxIterations ?? 10,
|
|
139
|
+
...(guards.wallClockMs === undefined ? {} : { wallClockMs: guards.wallClockMs }),
|
|
140
|
+
...(guards.tokenBudget === undefined ? {} : { tokenBudget: guards.tokenBudget }),
|
|
141
|
+
...(options.context === undefined ? {} : { context: options.context }),
|
|
142
|
+
onCycle: (progress) => {
|
|
143
|
+
currentReporter().emit({
|
|
144
|
+
type: "cycle_progress",
|
|
145
|
+
iteration: progress.iteration,
|
|
146
|
+
...(progress.measure === undefined ? {} : { measure: progress.measure }),
|
|
147
|
+
...(progress.usage === undefined ? {} : { usage: progress.usage }),
|
|
148
|
+
...(progress.contextPressure === undefined ? {} : { contextPressure: progress.contextPressure }),
|
|
149
|
+
});
|
|
150
|
+
if (options.onCycle !== undefined && progress.measure !== undefined) {
|
|
151
|
+
options.onCycle({
|
|
152
|
+
iteration: progress.iteration,
|
|
153
|
+
measure: progress.measure,
|
|
154
|
+
...(progress.usage === undefined ? {} : { usage: progress.usage }),
|
|
155
|
+
...(progress.contextPressure === undefined ? {} : { contextPressure: progress.contextPressure }),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (summary.isErr()) {
|
|
162
|
+
return err(summary.error);
|
|
163
|
+
}
|
|
164
|
+
const value = summary.value;
|
|
165
|
+
return ok({
|
|
166
|
+
state: value.lastEvaluation.state,
|
|
167
|
+
stopReason: value.stop,
|
|
168
|
+
iterations: value.iterations,
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Run the cycle body once, threading state through `.step()`s and folding `.reason()` usage. */
|
|
174
|
+
private async runBody(state: S): Promise<Result<BodyResult<S>, RuntimeError>> {
|
|
175
|
+
let current = state;
|
|
176
|
+
let usage: Usage | undefined;
|
|
177
|
+
const observations: Observation[] = [];
|
|
178
|
+
for (const item of this.items) {
|
|
179
|
+
if (item.kind === "step") {
|
|
180
|
+
current = await item.body(current);
|
|
181
|
+
observations.push({ id: item.name, content: stringifyObservation(current) });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const outcome = await item.run();
|
|
185
|
+
if (outcome.type === "failed") {
|
|
186
|
+
return err(outcome.error);
|
|
187
|
+
}
|
|
188
|
+
if (outcome.type === "cancelled") {
|
|
189
|
+
return err(backendFailed(item.tag, "reason verb cancelled before completion"));
|
|
190
|
+
}
|
|
191
|
+
const reported = outcome.result.usage;
|
|
192
|
+
if (reported !== undefined) {
|
|
193
|
+
usage = usage === undefined ? reported : addUsage(usage, reported);
|
|
194
|
+
}
|
|
195
|
+
observations.push({ id: `reason:${item.tag}`, content: stringifyObservation(outcome.result.output) });
|
|
196
|
+
}
|
|
197
|
+
return ok(usage === undefined ? { state: current, observations } : { state: current, usage, observations });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The loop has a variant iff a custom measure or a termination (preset/variant) was supplied. */
|
|
201
|
+
private hasVariant(): boolean {
|
|
202
|
+
return this.measureFn !== undefined || this.termination !== undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Normalize the variant to one async measure; priority: `.measure()` > `.until()`. */
|
|
206
|
+
private resolveVariant(): (state: S, cycle: number) => Promise<number> {
|
|
207
|
+
const measureFn = this.measureFn;
|
|
208
|
+
if (measureFn !== undefined) {
|
|
209
|
+
return (state) => Promise.resolve(measureFn(state));
|
|
210
|
+
}
|
|
211
|
+
const termination = this.termination;
|
|
212
|
+
if (termination !== undefined) {
|
|
213
|
+
if (isPreset(termination)) {
|
|
214
|
+
const preset = termination;
|
|
215
|
+
return (state, cycle) => Promise.resolve(preset.measure(state, cycle));
|
|
216
|
+
}
|
|
217
|
+
const variant = termination;
|
|
218
|
+
return (state) => Promise.resolve(variant.measure(state));
|
|
219
|
+
}
|
|
220
|
+
// Unreachable after the contract check; an unguarded loop rests on its ceiling.
|
|
221
|
+
return () => Promise.resolve(Number.POSITIVE_INFINITY);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Explicit `.guard()` values win; a preset's default seatbelts fill the rest (design D3). */
|
|
225
|
+
private resolveGuards(): LoopGuards {
|
|
226
|
+
const termination = this.termination;
|
|
227
|
+
const presetGuards = termination !== undefined && isPreset(termination) ? termination.guards : undefined;
|
|
228
|
+
const maxIterations = this.guards.maxIterations ?? presetGuards?.maxIterations;
|
|
229
|
+
const wallClockMs = this.guards.wallClockMs ?? presetGuards?.wallClockMs;
|
|
230
|
+
const tokenBudget = this.guards.tokenBudget ?? presetGuards?.tokenBudget;
|
|
231
|
+
return {
|
|
232
|
+
...(maxIterations === undefined ? {} : { maxIterations }),
|
|
233
|
+
...(wallClockMs === undefined ? {} : { wallClockMs }),
|
|
234
|
+
...(tokenBudget === undefined ? {} : { tokenBudget }),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Build the cyclic graph (one declared back-edge) and enforce termination by construction. */
|
|
239
|
+
private enforceContract(): Result<void, TerminationContractError> {
|
|
240
|
+
const ids = this.nodeIds();
|
|
241
|
+
const first = ids.at(0);
|
|
242
|
+
const last = ids.at(-1);
|
|
243
|
+
if (first === undefined || last === undefined) {
|
|
244
|
+
return ok(undefined); // no body ⇒ no cycle to guard
|
|
245
|
+
}
|
|
246
|
+
const edges: Edge[] = [];
|
|
247
|
+
for (let i = 0; i + 1 < ids.length; i += 1) {
|
|
248
|
+
const from = ids[i];
|
|
249
|
+
const to = ids[i + 1];
|
|
250
|
+
if (from !== undefined && to !== undefined) {
|
|
251
|
+
edges.push({ from, to });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const backEdge: Edge = { from: last, to: first };
|
|
255
|
+
const report = analyzeGraph({
|
|
256
|
+
nodes: ids,
|
|
257
|
+
edges: [...edges, backEdge],
|
|
258
|
+
declaredBackEdges: [backEdge],
|
|
259
|
+
});
|
|
260
|
+
const guarded = this.hasVariant();
|
|
261
|
+
return enforceTerminationContract(report, () => guarded);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Unique, loop-namespaced node ids in authored order (so contract errors name the loop). */
|
|
265
|
+
private nodeIds(): string[] {
|
|
266
|
+
const used = new Set<string>();
|
|
267
|
+
const ids: string[] = [];
|
|
268
|
+
for (const item of this.items) {
|
|
269
|
+
const base = `${this.name}:${item.kind === "reason" ? "reason" : item.name}`;
|
|
270
|
+
let id = base;
|
|
271
|
+
let suffix = 1;
|
|
272
|
+
while (used.has(id)) {
|
|
273
|
+
id = `${base}#${String(suffix)}`;
|
|
274
|
+
suffix += 1;
|
|
275
|
+
}
|
|
276
|
+
used.add(id);
|
|
277
|
+
ids.push(id);
|
|
278
|
+
}
|
|
279
|
+
return ids;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isPreset<S>(termination: TerminationPreset<S> | LoopVariant<S>): termination is TerminationPreset<S> {
|
|
284
|
+
return "kind" in termination;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function addUsage(a: Usage, b: Usage): Usage {
|
|
288
|
+
const reasoning = (a.reasoning ?? 0) + (b.reasoning ?? 0);
|
|
289
|
+
const base = { input: a.input + b.input, output: a.output + b.output };
|
|
290
|
+
return reasoning > 0 ? { ...base, reasoning } : base;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function stringifyObservation(value: unknown): string {
|
|
294
|
+
if (typeof value === "string") {
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
return JSON.stringify(value);
|
|
299
|
+
} catch {
|
|
300
|
+
return String(value);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Entry point for the declarative loop builder (task 5.1). */
|
|
305
|
+
export function loop<S = unknown>(name: string): LoopBuilder<S> {
|
|
306
|
+
return new LoopBuilderImpl<S>(name);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Preset termination archetypes (design D3 / task 5.2). Each bundles a loop variant whose
|
|
310
|
+
// measure floors at 0; choosing one satisfies the termination contract with no author math.
|
|
311
|
+
|
|
312
|
+
/** TDD / compiler loop: drive failing tests/gates to zero. */
|
|
313
|
+
export function untilGatesGreen(): TerminationPreset<GatesState> {
|
|
314
|
+
return { kind: "untilGatesGreen", measure: (state) => Math.max(0, state.failingGates) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The "Ralph" case: drive the manifest's pending-task count to zero (variant from L05 projection). */
|
|
318
|
+
export function untilManifestComplete(): TerminationPreset<TaskManifest> {
|
|
319
|
+
return { kind: "untilManifestComplete", measure: (state) => manifestMeasure(state) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Today's review/fix loop: drive the open-issue count to zero. */
|
|
323
|
+
export function untilNoIssues(): TerminationPreset<readonly unknown[]> {
|
|
324
|
+
return { kind: "untilNoIssues", measure: (state) => state.length };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Confidence loop: drive `1 − confidence` to the threshold floor. */
|
|
328
|
+
export function untilConfident(threshold: number): TerminationPreset<ConfidenceState> {
|
|
329
|
+
return { kind: "untilConfident", measure: (state) => Math.max(0, threshold - state.confidence) };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Bounded-count escape hatch: the variant is the remaining iterations; converges after `n`. */
|
|
333
|
+
export function times(n: number): TerminationPreset {
|
|
334
|
+
const ceiling = Math.max(0, Math.floor(n));
|
|
335
|
+
return {
|
|
336
|
+
kind: "times",
|
|
337
|
+
measure: (_state, cycle) => Math.max(0, ceiling - cycle),
|
|
338
|
+
guards: { maxIterations: ceiling },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Opt-in fan-out / fan-in combinators (spec loop-builder; design D9; tasks 6.1-6.4). They live
|
|
343
|
+
// in ./fanout.ts (Effect-free, building on the engine's plain `runBoundedBranches` seam) and are
|
|
344
|
+
// surfaced here so the loop authoring module exposes them; omitting them leaves the single-cycle
|
|
345
|
+
// loop() surface above unchanged.
|
|
346
|
+
export * from "../fanout.ts";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Result } from "neverthrow";
|
|
2
|
+
|
|
3
|
+
import type { AutonomousRequest, LlmBackend } from "../../backends/index.ts";
|
|
4
|
+
import type { FlowOverrides } from "../../flow/index.ts";
|
|
5
|
+
import type { BackendTag, RuntimeError, Usage } from "../../model/index.ts";
|
|
6
|
+
import type { LoopExecutionContextOptions, LoopContextPressure } from "../execution.ts";
|
|
7
|
+
import type { TerminationContractError } from "../termination-contract.ts";
|
|
8
|
+
|
|
9
|
+
// Declarative loop() builder — the Effect-free authoring front door (spec loop-builder).
|
|
10
|
+
// Lowers to flow() + loop execution. No Effect type may appear
|
|
11
|
+
// here (design D2 facade gate). The single-cycle case reads like a guarded `while`: no
|
|
12
|
+
// graph, fan-out, Effect, queue, or conversation symbol surfaces in authored source.
|
|
13
|
+
|
|
14
|
+
/** Surfaced from the internal contract module so callers can discriminate a build failure. */
|
|
15
|
+
export type { TerminationContractError } from "../termination-contract.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A loop variant (design D3): a measure bounded below — floor `0` by convention — that the
|
|
19
|
+
* loop drives toward zero. `.measure(fn)` supplies one directly; presets bundle one.
|
|
20
|
+
*/
|
|
21
|
+
export interface LoopVariant<S = unknown> {
|
|
22
|
+
/** Current measure; the loop converges when this reaches `0`. */
|
|
23
|
+
measure(state: S): Promise<number> | number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PresetKind =
|
|
27
|
+
| "untilGatesGreen"
|
|
28
|
+
| "untilManifestComplete"
|
|
29
|
+
| "untilNoIssues"
|
|
30
|
+
| "untilConfident"
|
|
31
|
+
| "times";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A preset termination archetype (design D3). Choosing one satisfies the termination contract
|
|
35
|
+
* without the author authoring variant math. The bundled `measure` is the loop variant (floor
|
|
36
|
+
* `0`); `cycle` is the completed-iteration count, needed by count-based presets like `times`.
|
|
37
|
+
* `guards` are sane default seatbelts the preset contributes; an explicit `.guard()` overrides.
|
|
38
|
+
*/
|
|
39
|
+
export interface TerminationPreset<S = unknown> {
|
|
40
|
+
readonly kind: PresetKind;
|
|
41
|
+
readonly measure: (state: S, cycle: number) => number;
|
|
42
|
+
readonly guards?: LoopGuards;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fan-in join policies — all four ship in v1 (design D9). Wired by L07 `fanIn`. */
|
|
46
|
+
export type JoinPolicy = "barrier" | "race" | "quorum" | "reduce";
|
|
47
|
+
|
|
48
|
+
/** Seatbelt guards layered under the variant (design D3): ceiling, wall-clock, token budget. */
|
|
49
|
+
export interface LoopGuards {
|
|
50
|
+
readonly maxIterations?: number;
|
|
51
|
+
readonly wallClockMs?: number;
|
|
52
|
+
readonly tokenBudget?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Discriminated stop reason; extends the fixLoop stops with `budget-exhausted`/`cancelled` (design D3). */
|
|
56
|
+
export type LoopStopReason =
|
|
57
|
+
| "converged"
|
|
58
|
+
| "unfixable"
|
|
59
|
+
| "stuck"
|
|
60
|
+
| "timeout"
|
|
61
|
+
| "ceiling"
|
|
62
|
+
| "budget-exhausted"
|
|
63
|
+
| "cancelled";
|
|
64
|
+
|
|
65
|
+
/** Outcome of a completed loop run. */
|
|
66
|
+
export interface LoopOutcome<S = unknown> {
|
|
67
|
+
readonly state: S;
|
|
68
|
+
readonly stopReason: LoopStopReason;
|
|
69
|
+
readonly iterations: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Per-cycle observability report emitted after each completed cycle when `onCycle` is set. The
|
|
74
|
+
* `measure` comes from the loop variant (the L05 manifest projection), so a record fed straight to
|
|
75
|
+
* `WorkflowMonitor.recordCycle` stays consistent with the termination variant (spec
|
|
76
|
+
* execution-observability). `usage` omitted ⇒ the backend reported none this cycle.
|
|
77
|
+
*/
|
|
78
|
+
export interface LoopCycleReport {
|
|
79
|
+
readonly iteration: number;
|
|
80
|
+
readonly measure: number;
|
|
81
|
+
readonly usage?: Usage;
|
|
82
|
+
readonly contextPressure?: LoopContextPressure;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Flow setup supplied to a run; `overrides` is how tests inject fake accessors (flow-runtime spec). */
|
|
86
|
+
export interface LoopRunOptions {
|
|
87
|
+
readonly args?: readonly string[];
|
|
88
|
+
readonly overrides?: FlowOverrides;
|
|
89
|
+
readonly context?: LoopExecutionContextOptions;
|
|
90
|
+
/** Optional observability hook; receives a {@link LoopCycleReport} after each completed cycle. */
|
|
91
|
+
readonly onCycle?: (cycle: LoopCycleReport) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A `.run()` fails either at build (unguarded back-edge) or at runtime (a step/reason error). */
|
|
95
|
+
export type LoopRunError = RuntimeError | TerminationContractError;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Chainable loop builder. The single-cycle case reads like a guarded `while` and needs no
|
|
99
|
+
* graph/fan-out/Effect knowledge. `.reason()` is the single LLM verb; `.step()` is a
|
|
100
|
+
* deterministic transform; `.measure()`/`.until()` set the termination variant; `.guard()`
|
|
101
|
+
* adds seatbelts. `.run()` lowers to `flow()` + loop execution.
|
|
102
|
+
*/
|
|
103
|
+
export interface LoopBuilder<S = unknown> {
|
|
104
|
+
/** The single LLM verb. Drives a backend autonomously each cycle; hides the conversation. */
|
|
105
|
+
reason<B extends BackendTag, Output = unknown>(
|
|
106
|
+
backend: LlmBackend<B>,
|
|
107
|
+
request: AutonomousRequest<Output, B>
|
|
108
|
+
): LoopBuilder<S>;
|
|
109
|
+
/** A deterministic step that transforms the threaded loop state. */
|
|
110
|
+
step(name: string, body: (state: S) => Promise<S> | S): LoopBuilder<S>;
|
|
111
|
+
/** Power-user variant override (design D3); takes precedence over a preset. */
|
|
112
|
+
measure(fn: (state: S) => Promise<number> | number): LoopBuilder<S>;
|
|
113
|
+
/** Set the termination variant from a preset archetype or a custom variant. */
|
|
114
|
+
until(termination: TerminationPreset<S> | LoopVariant<S>): LoopBuilder<S>;
|
|
115
|
+
/** Layer seatbelt guards under the variant. */
|
|
116
|
+
guard(opts: LoopGuards): LoopBuilder<S>;
|
|
117
|
+
/** Build (enforcing the termination contract) then run; returns the outcome and a stop reason. */
|
|
118
|
+
run(initial?: S, options?: LoopRunOptions): Promise<Result<LoopOutcome<S>, LoopRunError>>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** State projection read by the `untilGatesGreen` preset. */
|
|
122
|
+
export interface GatesState {
|
|
123
|
+
/** Failing tests/gates remaining; the loop converges as this reaches `0`. */
|
|
124
|
+
readonly failingGates: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** State projection read by the `untilConfident` preset. */
|
|
128
|
+
export interface ConfidenceState {
|
|
129
|
+
/** Confidence in `[0, 1]`; the loop converges once it reaches the threshold. */
|
|
130
|
+
readonly confidence: number;
|
|
131
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { CompactionConfig } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
// Aggressive-default staged context compaction (design D10; task 7.1). Once managed context is
|
|
4
|
+
// enabled, compaction runs by TOKEN PRESSURE — the "small windows = smart agents" principle: a
|
|
5
|
+
// tight working window forces sharper agent behaviour and cheaper turns. As pressure rises past the
|
|
6
|
+
// small working window, the same observation history is compacted in escalating stages, IN ORDER:
|
|
7
|
+
//
|
|
8
|
+
// mask → keep every observation record but drop its body to a short stub (cheapest, lowest
|
|
9
|
+
// information loss): the agent still sees that the step happened.
|
|
10
|
+
// prune → discard the oldest stubs that no longer fit, keeping the newest history that does.
|
|
11
|
+
// summarize → when even pruning would discard most of the record (wholesale loss), collapse the
|
|
12
|
+
// whole history span into a single bounded summary observation instead of dropping it.
|
|
13
|
+
//
|
|
14
|
+
// Each stage is reached only when the previous one cannot bring the context back within the window,
|
|
15
|
+
// so the stages applied report the pressure tier. `compact` is a pure transform (no I/O); large
|
|
16
|
+
// individual outputs are kept out of context separately by the offload path (see ./offload.ts), so
|
|
17
|
+
// by the time observations reach here each one is already bounded.
|
|
18
|
+
|
|
19
|
+
/** ~4 chars per token — the same crude estimate used across the loop module (cf. fanout budgets). */
|
|
20
|
+
const CHARS_PER_TOKEN = 4;
|
|
21
|
+
/** The stub a masked observation's body collapses to; its own token cost is the mask floor. */
|
|
22
|
+
const MASK_PLACEHOLDER = "⟦masked⟧";
|
|
23
|
+
/** Bounded size of the single observation produced by the summarize stage. */
|
|
24
|
+
const SUMMARY_TOKEN_CAP = 48;
|
|
25
|
+
const SUMMARY_CHAR_CAP = SUMMARY_TOKEN_CAP * CHARS_PER_TOKEN;
|
|
26
|
+
/** If fitting the window would force pruning more than this fraction of history, summarize instead. */
|
|
27
|
+
const PRUNE_TO_SUMMARIZE_FRACTION = 0.5;
|
|
28
|
+
|
|
29
|
+
/** Token cost of one masked stub — the irreducible floor of the mask stage. */
|
|
30
|
+
const MASK_TOKENS = estimateTokens(MASK_PLACEHOLDER);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Aggressive defaults applied once managed context is enabled (design D10): a small working window
|
|
34
|
+
* and an aggressive offload threshold. Authors MAY raise these, but enabled context gets tight
|
|
35
|
+
* defaults out of the box.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
|
|
38
|
+
workingWindowTokens: 2000,
|
|
39
|
+
offloadThresholdChars: 8000,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The escalating compaction stages, lowest pressure first. */
|
|
43
|
+
export type CompactionStage = "mask" | "prune" | "summarize";
|
|
44
|
+
|
|
45
|
+
/** One unit of loop context — a tool/step observation the agent may later read. */
|
|
46
|
+
export interface Observation {
|
|
47
|
+
/** Stable identifier, preserved through masking so the record of the step survives. */
|
|
48
|
+
readonly id: string;
|
|
49
|
+
/** The observation body; what masking and summarization compress. */
|
|
50
|
+
readonly content: string;
|
|
51
|
+
/** Pinned observations (goal, manifest) are never compacted. */
|
|
52
|
+
readonly pinned?: boolean;
|
|
53
|
+
/** Precise token cost; defaults to a ~4-chars-per-token estimate of `content`. */
|
|
54
|
+
readonly tokens?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CompactionResult {
|
|
58
|
+
/** The compacted context: pinned, then the compacted history span, then the verbatim recent tail. */
|
|
59
|
+
readonly observations: readonly Observation[];
|
|
60
|
+
/** Stages applied, in order — empty when there was no pressure, up to all three under heavy load. */
|
|
61
|
+
readonly stagesApplied: readonly CompactionStage[];
|
|
62
|
+
readonly tokensBefore: number;
|
|
63
|
+
readonly tokensAfter: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Crude token estimate from character length; shared so masking/summary budgets stay consistent. */
|
|
67
|
+
export function estimateTokens(text: string): number {
|
|
68
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tokensOf(observation: Observation): number {
|
|
72
|
+
return observation.tokens ?? estimateTokens(observation.content);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function totalTokens(observations: readonly Observation[]): number {
|
|
76
|
+
return observations.reduce((sum, observation) => sum + tokensOf(observation), 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compact `observations` to fit the small working window, escalating mask → prune → summarize only
|
|
81
|
+
* as far as the token pressure requires. Pure: returns a new context plus the stages it applied. The
|
|
82
|
+
* result is kept within `config.workingWindowTokens` except when a single un-offloaded recent
|
|
83
|
+
* observation alone exceeds the window — offload oversized outputs (see ./offload.ts) to avoid that.
|
|
84
|
+
*/
|
|
85
|
+
export function compact(
|
|
86
|
+
observations: readonly Observation[],
|
|
87
|
+
config: CompactionConfig = DEFAULT_COMPACTION_CONFIG,
|
|
88
|
+
): CompactionResult {
|
|
89
|
+
const window = config.workingWindowTokens;
|
|
90
|
+
const tokensBefore = totalTokens(observations);
|
|
91
|
+
if (tokensBefore <= window) {
|
|
92
|
+
// No pressure — nothing is touched.
|
|
93
|
+
return { observations: [...observations], stagesApplied: [], tokensBefore, tokensAfter: tokensBefore };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { pinned, recent, history } = partition(observations, window);
|
|
97
|
+
const fixedTokens = totalTokens(pinned) + totalTokens(recent); // pinned + recent tail are never compacted
|
|
98
|
+
|
|
99
|
+
// Stage 1 — MASK every history observation's body down to a stub.
|
|
100
|
+
const masked = history.map(maskObservation);
|
|
101
|
+
if (fixedTokens + totalTokens(masked) <= window) {
|
|
102
|
+
return assemble(pinned, masked, recent, ["mask"], tokensBefore);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Stage 2 — PRUNE the oldest stubs that no longer fit, keeping the newest that do — UNLESS that
|
|
106
|
+
// would discard most of the history, which is wholesale loss better handled by summarization.
|
|
107
|
+
const room = Math.max(0, window - fixedTokens);
|
|
108
|
+
const keepCount = Math.min(masked.length, Math.floor(room / MASK_TOKENS));
|
|
109
|
+
const dropCount = masked.length - keepCount;
|
|
110
|
+
if (dropCount <= masked.length * PRUNE_TO_SUMMARIZE_FRACTION) {
|
|
111
|
+
const pruned = masked.slice(masked.length - keepCount); // keep the newest survivors
|
|
112
|
+
return assemble(pinned, pruned, recent, ["mask", "prune"], tokensBefore);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Stage 3 — SUMMARIZE the whole history span into one bounded observation, preserving its gist
|
|
116
|
+
// rather than dropping the majority of it. Guaranteed to fit: the recent tail reserves room for it.
|
|
117
|
+
const summary = summarizeObservations(history);
|
|
118
|
+
return assemble(pinned, [summary], recent, ["mask", "prune", "summarize"], tokensBefore);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Split observations into the never-compacted pinned set, the verbatim recent tail that fills the
|
|
123
|
+
* working window (newest first, reserving room for a terminal summary), and the older history span.
|
|
124
|
+
* At least the single newest non-pinned observation is always kept verbatim.
|
|
125
|
+
*/
|
|
126
|
+
function partition(
|
|
127
|
+
observations: readonly Observation[],
|
|
128
|
+
window: number,
|
|
129
|
+
): { pinned: Observation[]; recent: Observation[]; history: Observation[] } {
|
|
130
|
+
const pinned = observations.filter((observation) => observation.pinned === true);
|
|
131
|
+
const flow = observations.filter((observation) => observation.pinned !== true); // chronological, oldest→newest
|
|
132
|
+
const recentBudget = Math.max(0, window - totalTokens(pinned) - SUMMARY_TOKEN_CAP);
|
|
133
|
+
|
|
134
|
+
const recent: Observation[] = [];
|
|
135
|
+
let used = 0;
|
|
136
|
+
for (let i = flow.length - 1; i >= 0; i--) {
|
|
137
|
+
const observation = flow[i];
|
|
138
|
+
if (observation === undefined) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const cost = tokensOf(observation);
|
|
142
|
+
if (recent.length > 0 && used + cost > recentBudget) {
|
|
143
|
+
break; // always keep the newest observation; stop once the tail would overflow the budget
|
|
144
|
+
}
|
|
145
|
+
recent.unshift(observation);
|
|
146
|
+
used += cost;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const history = flow.slice(0, flow.length - recent.length);
|
|
150
|
+
return { pinned, recent, history };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Drop an observation's body to a stub while preserving its identity (the step still happened). */
|
|
154
|
+
function maskObservation(observation: Observation): Observation {
|
|
155
|
+
return { id: observation.id, content: MASK_PLACEHOLDER };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Collapse a history span into one bounded summary observation that names what it replaced. */
|
|
159
|
+
function summarizeObservations(history: readonly Observation[]): Observation {
|
|
160
|
+
const ids = history.map((observation) => observation.id).join(", ");
|
|
161
|
+
const full = `⟦summary of ${String(history.length)} earlier observations: ${ids}⟧`;
|
|
162
|
+
const content = full.length > SUMMARY_CHAR_CAP ? `${full.slice(0, SUMMARY_CHAR_CAP - 1)}…` : full;
|
|
163
|
+
return { id: "compaction-summary", content };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function assemble(
|
|
167
|
+
pinned: readonly Observation[],
|
|
168
|
+
historyReplacement: readonly Observation[],
|
|
169
|
+
recent: readonly Observation[],
|
|
170
|
+
stagesApplied: readonly CompactionStage[],
|
|
171
|
+
tokensBefore: number,
|
|
172
|
+
): CompactionResult {
|
|
173
|
+
const observations = [...pinned, ...historyReplacement, ...recent];
|
|
174
|
+
return { observations, stagesApplied, tokensBefore, tokensAfter: totalTokens(observations) };
|
|
175
|
+
}
|