safeloop 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charles Zeller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,451 @@
1
+ # Safeloop
2
+
3
+ Safeloop is a lightweight governance SDK for local AI agent loops.
4
+
5
+ It helps keep local agent runs reviewable, bounded, and easier to approve without turning the project into another coding-agent framework.
6
+
7
+ ## Why this exists
8
+
9
+ Local AI agent loops fail in predictable ways:
10
+ - repeated retries on the same error
11
+ - uncontrolled scope expansion
12
+ - token burn on unproductive attempts
13
+ - unsafe actions that should be reviewed before execution
14
+
15
+ This package gives you small governance primitives instead of a full agent stack. It is designed to stay boring, auditable, and easy to reason about.
16
+
17
+ ## Governance loop
18
+
19
+ - Policy Gate before execution
20
+ - Circuit Breaker during execution
21
+ - Action Ledger after/during execution
22
+ - Markdown Report for human review
23
+ - Live Simulation for proof
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install safeloop
29
+ ```
30
+
31
+ Zero runtime dependencies.
32
+
33
+ ## Quick start
34
+
35
+ ```typescript
36
+ import { createPolicyGate, createBreaker } from 'safeloop';
37
+
38
+ const gate = createPolicyGate({
39
+ oversightMode: 'HITL',
40
+ allowedFiles: ['README.md', 'src/**'],
41
+ allowedCommands: ['npm test', 'npm run build'],
42
+ blockedCommands: ['git push', 'npm publish'],
43
+ maxRisk: 'medium',
44
+ });
45
+
46
+ const decision = gate.evaluate({
47
+ task: 'Update docs and run validation',
48
+ requestedFiles: ['README.md'],
49
+ requestedCommands: ['npm test'],
50
+ risk: 'low',
51
+ });
52
+
53
+ if (!decision.allowed) {
54
+ throw new Error(decision.message);
55
+ }
56
+
57
+ const breaker = createBreaker({ maxRetries: 3 });
58
+ const result = await breaker.run(async () => ({ ok: true, _stepTokenCost: 50 }));
59
+
60
+ console.log(decision.message);
61
+ console.log(result.success);
62
+ ```
63
+
64
+ Run the live simulation from this repo after `npm install` or `npm ci`:
65
+
66
+ ```bash
67
+ npm run example:live-simulation
68
+ ```
69
+
70
+ The simulation is repo-local and uses the TypeScript example harness. It is for proof and review, not a security boundary.
71
+
72
+ ## API references
73
+
74
+ ### `createPolicyGate(config)`
75
+
76
+ Creates a pre-run approval gate for local agent work. It evaluates requested files, requested commands, risk, and approval state.
77
+
78
+ Returns a decision with:
79
+ - `allowed`
80
+ - `requiresApproval`
81
+ - `reasons`
82
+ - `violations`
83
+ - `message`
84
+
85
+ ### `createAgentRunLedger(metadata)`
86
+
87
+ Creates an in-memory run ledger for prompts, commands, changed files, validations, scope checks, approvals, and closeout.
88
+
89
+ Common methods:
90
+ - `recordPrompt()`
91
+ - `recordCommand()`
92
+ - `recordChangedFiles()`
93
+ - `recordValidation()`
94
+ - `recordScopeCheck()`
95
+ - `recordApproval()`
96
+ - `close()`
97
+ - `toMarkdown()`
98
+
99
+ Disclaimer: this package provides governance primitives, not a complete security boundary. Users must still sandbox tools, restrict credentials, review diffs, and apply least-privilege access.
100
+
101
+ ## Breaker API quick start
102
+
103
+ ```typescript
104
+ import { createBreaker } from 'safeloop';
105
+
106
+ const breaker = createBreaker({
107
+ maxRetries: 3,
108
+ maxRepeatedErrors: 2,
109
+ tokenBudget: { perStep: 1000, perTask: 5000 },
110
+ });
111
+
112
+ async function myAgentTask(ctx) {
113
+ // ctx.attempt - current attempt number (1-based)
114
+ // ctx.tokenUsed - tokens consumed so far
115
+ // ctx.signal - AbortSignal (check ctx.signal.aborted for cancellation)
116
+ // ctx.log(entry) - add custom audit entries
117
+ // ctx.proposeScopeChange(desc, goals) - request scope expansion
118
+
119
+ // Report token usage via the return value:
120
+ return { result: 'done', _stepTokenCost: 150 };
121
+ }
122
+
123
+ const result = await breaker.run(myAgentTask);
124
+
125
+ if (!result.success) {
126
+ console.log(result.escalationMessage);
127
+ // The agent loop was stopped.
128
+ //
129
+ // What failed: ...
130
+ // What was tried: ...
131
+ // Why it stopped: ...
132
+ // What a human should decide next: ...
133
+ }
134
+ ```
135
+
136
+ ## API
137
+
138
+ ### `createBreaker(config?)`
139
+
140
+ Returns a `Breaker` instance.
141
+
142
+ | Option | Type | Default | Description |
143
+ |--------|------|---------|-------------|
144
+ | `maxRetries` | number | `3` | Maximum attempts before hard stop. Set to `0` for a single attempt with no retries. |
145
+ | `maxRepeatedErrors` | number | `2` | Number of consecutive identical errors before escalation. Set to `0` to disable. |
146
+ | `tokenBudget.perStep` | number | `Infinity` | Maximum estimated tokens for a single step. |
147
+ | `tokenBudget.perTask` | number | `Infinity` | Maximum estimated tokens across all attempts. |
148
+ | `scopeFreeze` | boolean | `true` | When true, tasks may not add new goals without calling `proposeScopeChange()`. |
149
+
150
+ ### `breaker.run(taskFn)`
151
+
152
+ Executes the task function with retry logic. Returns a `BreakerResult`.
153
+
154
+ The task function receives a `BreakerContext` with:
155
+
156
+ - **`attempt`** — current attempt number (1-based).
157
+ - **`tokenUsed`** — estimated tokens consumed so far in this run, including ALL attempts (both successful and failed).
158
+ - **`signal`** — an `AbortSignal`. When `trip()` is called, this signal is aborted. Cooperative tasks should check `ctx.signal.aborted` and exit cleanly.
159
+ - **`log(entry)`** — add a custom entry to the audit log. Entry shape: `{ type, message, metadata? }`.
160
+ - **`recordTokenUsage(cost)`** — explicitly record token usage for the current attempt. Adds to the cumulative `tokenUsed` and logs a `token_usage` audit entry.
161
+ - **`proposeScopeChange(description, goals)`** — request approval to expand scope. Returns `false` when `scopeFreeze` is enabled, `true` when disabled. Calling this when `scopeFreeze` is enabled will cause the breaker to trip after the task completes.
162
+
163
+ **Token tracking** — the library accumulates estimated token usage across all attempts. Tokens can be reported three ways:
164
+
165
+ 1. **Return value**: return `{ _stepTokenCost: 150 }` or `{ _tokenEstimate: 150 }` from your task. For compatibility with some consumers, `tokensUsed: 150` is also accepted.
166
+ 2. **Error object**: set `error._stepTokenCost` or `error._tokenEstimate` before throwing.
167
+ 3. **Explicit**: call `ctx.recordTokenUsage(150)` at any point during the task.
168
+
169
+ These are conventions, not enforced counts — the library does not count tokens itself.
170
+
171
+ ### `breaker.trip(reason)`
172
+
173
+ Manual kill switch. Aborts the `AbortSignal` passed to `ctx.signal`. After the current task attempt completes, the breaker returns a `kill_switch` result. For cooperative tasks that check `ctx.signal.aborted`, this allows clean shutdown.
174
+
175
+ ### `breaker.reset()`
176
+
177
+ Clears all internal state (audit log, attempt count, kill switch flag, etc.). The breaker can be reused after reset.
178
+
179
+ ### `breaker.status()`
180
+
181
+ Returns `{ isTripped: boolean, isKilled: boolean, attempts: number, tripReason: string | null }`.
182
+
183
+ ### `breaker.log()`
184
+
185
+ Returns a copy of all accumulated audit entries.
186
+
187
+ ### Result shape
188
+
189
+ ```typescript
190
+ {
191
+ success: boolean, // true only if the task completed without being stopped
192
+ stoppedBy: string, // 'max_retries' | 'repeated_error' | 'token_budget_task'
193
+ // | 'token_budget_step' | 'scope_freeze' | 'kill_switch' | ''
194
+ attempts: number, // total attempts made
195
+ tokenEstimate: number, // estimated tokens consumed (cumulative across ALL attempts)
196
+ lastError: string | null, // error message from the last failed attempt
197
+ escalationMessage: string | null, // human-readable message explaining what failed,
198
+ // what was tried, why it stopped, and next steps
199
+ auditEntries: AuditEntry[], // full audit trail for this run
200
+ data?: unknown, // return value of taskFn on success
201
+ }
202
+ ```
203
+
204
+ ### Audit entry shape
205
+
206
+ ```typescript
207
+ {
208
+ timestamp: number, // Date.now() when the entry was created
209
+ type: 'attempt' | 'retry' | 'failure' | 'budget_check' | 'breaker_trip'
210
+ | 'kill_switch' | 'escalation' | 'scope_denied' | 'scope_proposed' | 'token_usage',
211
+ message: string,
212
+ metadata?: Record<string, unknown>,
213
+ }
214
+ ```
215
+
216
+ ## Config example
217
+
218
+ ```typescript
219
+ // All options shown with their defaults:
220
+ const breaker = createBreaker({
221
+ maxRetries: 3,
222
+ maxRepeatedErrors: 2,
223
+ tokenBudget: {
224
+ perStep: Infinity,
225
+ perTask: Infinity,
226
+ },
227
+ scopeFreeze: true,
228
+ });
229
+ ```
230
+
231
+ ## Presets
232
+
233
+ Use the built-in `BREAKER_PRESETS` for common agent-loop safety modes:
234
+
235
+ ```typescript
236
+ import { createBreaker, BREAKER_PRESETS } from 'safeloop';
237
+
238
+ const breaker = createBreaker(BREAKER_PRESETS.standardCodingAgent);
239
+ ```
240
+
241
+ | Preset | maxRetries | maxRepeatedErrors | perStep | perTask | scopeFreeze |
242
+ |--------|-----------|-------------------|---------|---------|-------------|
243
+ | `conservativeCodingAgent` | 1 | 1 | 4000 | 12000 | true |
244
+ | `standardCodingAgent` | 2 | 2 | 8000 | 30000 | true |
245
+ | `exploratoryResearchAgent` | 3 | 2 | 12000 | 60000 | false |
246
+
247
+ ### Hermes/OpenCode helpers
248
+
249
+ For Hermes/OpenCode-style loop engineering workflows, the package includes two small convenience helpers:
250
+
251
+ ```typescript
252
+ import {
253
+ createCodingAgentBreaker,
254
+ toMarkdownReport,
255
+ } from 'safeloop';
256
+
257
+ const breaker = createCodingAgentBreaker();
258
+ const result = await breaker.run(runCodingLoop);
259
+
260
+ console.log(toMarkdownReport(result));
261
+ ```
262
+
263
+ - `createCodingAgentBreaker(config?)` uses `BREAKER_PRESETS.standardCodingAgent` by default and lets you override only the settings you need.
264
+ - `toMarkdownReport(result)` turns a `BreakerResult` into a compact Markdown summary you can print after a run.
265
+ - `breaker.run(...)` is async, so always `await` it before passing the result into `toMarkdownReport(...)`.
266
+
267
+ These helpers are meant to make local agent-loop experiments easier to read, easier to tune, and easier to hand back to a human when the breaker trips.
268
+
269
+ ## Agent Action Ledger
270
+
271
+ The circuit breaker is the emergency brake.
272
+ The Agent Action Ledger is the audit trail.
273
+
274
+ Together they form the foundation for local AI agent governance:
275
+ - The breaker stops unsafe or runaway loops.
276
+ - The ledger records what the agent tried, changed, validated, and approved.
277
+ - Combined, they make agent runs easier to review, debug, and control.
278
+
279
+ Example:
280
+
281
+ ```typescript
282
+ import { createAgentRunLedger } from 'safeloop';
283
+
284
+ const ledger = createAgentRunLedger({
285
+ runId: 'run-001',
286
+ agent: 'Hermes',
287
+ executor: 'OpenCode',
288
+ repo: 'safeloop',
289
+ task: 'ship ledger v1',
290
+ allowedFiles: ['src/index.ts', 'tests/breaker.test.ts'],
291
+ startedAt: new Date().toISOString(),
292
+ });
293
+
294
+ ledger.recordPrompt('Add the Agent Action Ledger API.');
295
+ ledger.recordCommand('npm test', { exitCode: 0, summary: 'passed' });
296
+ ledger.recordValidation('npm test', 'passed');
297
+ ledger.close('completed');
298
+
299
+ console.log(ledger.toMarkdown());
300
+ ```
301
+
302
+ ## Policy Gate: approve before execution
303
+
304
+ Policy Gate runs before the agent starts.
305
+ Circuit Breaker supervises during execution.
306
+ Agent Action Ledger records what happened.
307
+
308
+ Together they form a small governance loop for local AI agents.
309
+
310
+ Example:
311
+
312
+ ```typescript
313
+ import { createPolicyGate } from 'safeloop';
314
+
315
+ const gate = createPolicyGate({
316
+ oversightMode: "HITL",
317
+ allowedFiles: ["README.md", "examples/**"],
318
+ allowedCommands: ["npm test", "npm run build", "git status", "git diff"],
319
+ blockedCommands: ["git push", "npm publish", "rm -rf"],
320
+ maxRisk: "medium",
321
+ });
322
+
323
+ const decision = gate.evaluate({
324
+ task: "Update README documentation",
325
+ requestedFiles: ["README.md"],
326
+ requestedCommands: ["npm test"],
327
+ risk: "low",
328
+ });
329
+
330
+ if (!decision.allowed) {
331
+ throw new Error(decision.message);
332
+ }
333
+ ```
334
+
335
+ Policy Gate uses simple, conservative matching:
336
+ - allowedFiles supports exact paths, `/*` for direct children, and `/**` for recursive folder access.
337
+ - Windows backslashes are normalized to forward slashes before matching.
338
+ - blockedCommands are matched by case-insensitive substring so obvious dangerous commands are caught.
339
+ - allowedCommands are matched by normalized exact command string.
340
+
341
+ ## Features
342
+
343
+ ### 1. Hard loop limit (`maxRetries`)
344
+
345
+ Stops after N attempts on the same task. Attempt 1 is the first try; attempts 2 through N+1 are retries. Default: 3 retries (4 total attempts).
346
+
347
+ ### 2. Token budget limit (`tokenBudget`)
348
+
349
+ Two independent limits:
350
+ - `perStep` — maximum tokens for a single step. Trips if a step's reported cost exceeds this.
351
+ - `perTask` — maximum tokens across all attempts. Trips if cumulative cost exceeds this.
352
+
353
+ Token counting is **estimated** — your task function reports `_stepTokenCost` or `_tokenEstimate` on its return value or on thrown error objects, or via `ctx.recordTokenUsage()`. Tokens are accumulated across **all** attempts, including failed ones. The library does not count tokens itself.
354
+
355
+ ### 3. Repeated error detection (`maxRepeatedErrors`)
356
+
357
+ When the same normalized error message appears N times consecutively (no alternating errors in between), the breaker trips with `repeated_error`. Errors are normalized by stripping stack traces — only the first line of the message is compared. Default threshold: 2.
358
+
359
+ Set to `0` to disable.
360
+
361
+ ### 4. Scope freeze (`scopeFreeze`)
362
+
363
+ Two detection mechanisms:
364
+
365
+ 1. **Explicit** — call `ctx.proposeScopeChange(description, goals)` to request scope expansion. Returns `false` when scope freeze is enabled. The breaker trips after the task completes.
366
+ 2. **Heuristic** — if the task returns an object containing `_newGoals`, `newGoals`, `_newTasks`, or `newTasks` as a non-empty array, the breaker trips.
367
+
368
+ Both are disabled when `scopeFreeze: false`.
369
+
370
+ ### 5. Kill switch (`trip()`)
371
+
372
+ Call `breaker.trip(reason)` to stop the current run. This:
373
+ - Sets `killSwitchEngaged` flag.
374
+ - Aborts the `AbortSignal` available via `ctx.signal`.
375
+ - After the current task attempt finishes, the breaker returns `kill_switch` result.
376
+
377
+ **Important**: The kill switch is cooperative. It signals cancellation via `AbortSignal`, but the running task must check `ctx.signal.aborted` to stop promptly. If the task is blocked on a native promise that never resolves, the breaker will wait for it. Always design your agent tasks to be cooperative by periodically checking the signal.
378
+
379
+ ### 6. Audit log
380
+
381
+ Every attempt, failure, retry, budget check, scope proposal/denial, and trip is recorded. Access via `result.auditEntries` or `breaker.log()`.
382
+
383
+ ### 7. Escalation messages
384
+
385
+ When the breaker trips, `result.escalationMessage` contains a structured human-readable message with four sections:
386
+ - **What failed** — description of the error or situation.
387
+ - **What was tried** — number of attempts made.
388
+ - **Why it stopped** — the specific threshold or condition that triggered the stop.
389
+ - **What a human should decide next** — suggested next steps.
390
+
391
+ ## AbortSignal example
392
+
393
+ ```typescript
394
+ const breaker = createBreaker();
395
+
396
+ // Simulate user pressing Ctrl+C after 2 seconds
397
+ setTimeout(() => breaker.trip('user cancelled'), 2000);
398
+
399
+ const result = await breaker.run(async (ctx) => {
400
+ for (let step = 0; step < 10; step++) {
401
+ // Check for cancellation before each step
402
+ if (ctx.signal.aborted) {
403
+ // Clean up and return
404
+ return `cancelled at step ${step}`;
405
+ }
406
+ // Do work...
407
+ await doSomeWork();
408
+ }
409
+ return 'completed';
410
+ });
411
+ ```
412
+
413
+ ## Scope freeze example
414
+
415
+ ```typescript
416
+ const breaker = createBreaker({ scopeFreeze: true });
417
+
418
+ const result = await breaker.run(async (ctx) => {
419
+ const approved = ctx.proposeScopeChange(
420
+ 'add new tasks',
421
+ ['write documentation', 'create examples'],
422
+ );
423
+ if (!approved) {
424
+ // Stay within original scope
425
+ return 'original task done';
426
+ }
427
+ return 'expanded task done';
428
+ });
429
+ // result.success === false
430
+ // result.stoppedBy === 'scope_freeze'
431
+ ```
432
+
433
+ ## Limitations
434
+
435
+ - **Token tracking is estimated**. The library relies on your task function to report `_stepTokenCost` or `_tokenEstimate`. It does not count tokens itself.
436
+ - **Kill switch is cooperative**. It signals via `AbortSignal` but cannot interrupt a non-cooperative task that never checks the signal or yields. Design tasks to periodically check `ctx.signal.aborted`.
437
+ - **Scope freeze heuristic is best-effort**. Detecting `_newGoals` / `newGoals` / `_newTasks` / `newTasks` on the return value is a simple convention, not a sandbox. The explicit `proposeScopeChange()` mechanism is the recommended approach.
438
+ - **Single-threaded**. Each breaker instance is designed for one agent loop at a time.
439
+ - **Error normalization is simple**. Only the first line of the error message is compared. Stack traces are ignored. If your errors have dynamic content (timestamps, request IDs) in the message line, consider normalizing them before throwing.
440
+
441
+ ## Design principles
442
+
443
+ - **Framework agnostic** — works with LangChain, Vercel AI SDK, OpenAI SDK, or custom agent loops.
444
+ - **Small and boring** — ~250 lines, zero runtime dependencies, easy to audit.
445
+ - **Safe by default** — sane defaults (3 retries, 2 repeated errors, scope freeze on).
446
+ - **Human override always available** — kill switch via `trip()` with cooperative signal.
447
+ - **Honest about failure** — structured results, clear escalation messages, documented limitations.
448
+
449
+ ## License
450
+
451
+ MIT