Files
waggle-os/docs/handoffs/2026-05-27-agent-core-review.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

175 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agent Core Review — `packages/agent/src/`
**Date:** 2026-05-27 · **Scope:** Core agent runtime · **Compared against:** Claude Code (tool-result-injection + composable middleware) and Hermes (minimal-state ReAct, conversation-as-state)
**HEAD:** `819571d`
> **TL;DR.** The agent runtime is functionally rich and well-instrumented but carries two god-shapes that hide an otherwise clean loop: `runAgentLoop` (540 lines in a single function) and `Orchestrator.autoSaveFromExchange` (277 lines of regex pattern-matching). Extracting four focused modules — SSE parser, tool executor, loop gates, pattern write-back — drops the core to a Claude-Code-shaped ~250-line loop and a Hermes-shaped ~500-line facade, with zero behavior change. **Highest-leverage single PR: extract `autoSaveFromExchange` (PR-A, ~1 day, ~277 lines moved verbatim).**
---
## 0. Systemic findings — leverage-ordered
These 15 patterns account for ~90% of the simplification opportunity. Each one can be addressed with a focused PR; most are pure extractions with no behavior change.
| # | Pattern | Where | Effect |
|---|---|---|---|
| **A-1** | God function — `runAgentLoop` is 540L inside one function body | `agent-loop.ts:142-681` | Hides a clean ~200L conversation loop under 340L of mixed concerns (SSE, retry, gates, governance, hooks, sanitize, callbacks) |
| **A-2** | God method — `Orchestrator.autoSaveFromExchange` is 277L of regex intent inference | `orchestrator.ts:753-1029` | Single largest extraction target; behavior is coherent enough to lift verbatim |
| **A-3** | Inline SSE parsing (~90L of stream reader + buffer + chunk decode + tool-call assembly) | `agent-loop.ts:333-420` | Reusable, table-testable in isolation; should be `lib/sse-parser.ts` |
| **A-4** | Inline tool execution sub-loop (~130L mixing governance, pre-hook, memory-hook, guard, execute, sanitize, callbacks, post-hooks) | `agent-loop.ts:539-669` | Should be `lib/tool-executor.ts` with explicit middleware chain (Claude Code shape) |
| **A-5** | D1 + D3 gates inlined in the no-tool-calls branch | `agent-loop.ts:459-507` | Each gate has the shape `(state) => correction \| null`; should be `lib/loop-gates.ts` |
| **A-6** | Inline retry policy split across 3 sites — rate-limit (429), server error (5xx), parse error | `agent-loop.ts:296-323, 423-432, 543-550` | Duplicated logic; should be `lib/retry-policy.ts` returning typed RetryDecision |
| **A-7** | 12 catch-up regex patterns + 11 preference patterns + 7 decision patterns + 6 style signals defined inline mid-function | `orchestrator.ts:593-598, 835-846, 894-902, 861-867` | Should be a module-level constants block (or eventually replaced by an LLM-based extractor — see A-15) |
| **A-8** | Implicit state machine — 8 scattered `let` vars juggling loop invariants (turn, retries × 2, accumulated content, tools used, gates × 2, preserved answer) | `agent-loop.ts:242-263` | Typed `LoopState` object would make invariants explicit and aid debugging |
| **A-9** | `Orchestrator` class mixes 6 responsibilities — workspace lifecycle / memory stats / context loading / prompt building / recall / autoSave / tool facade / 7 getters | `orchestrator.ts:112-1048` | Should be a thin facade delegating to focused modules (Hermes shape) |
| **A-10** | Comment archaeology — `// Review #6`, `// M8`, `// F29c`, `// E4`, `// R2`, `// B2 fix`, `// CR-#` etc. scattered through both files | Throughout | Useful breadcrumbs but pollute reading. Replace with single-line `// Why:` notes per CLAUDE.md §3.7; drop outdated |
| **A-11** | `any` types in stream parsing + non-streaming response handling | `agent-loop.ts:362, 423` | Should narrow to `unknown` + Zod schema OR a strict `ChatCompletionChunk` interface |
| **A-12** | Magic numbers — `MAX_RETRIES = 3`, `60_000`, `30_000`, `MIN_CONTENT_LENGTH = 30` etc. are extracted in `orchestrator.ts` (M16) but scattered raw in `agent-loop.ts` | `agent-loop.ts:250, 302, 319` | Apply M16 pattern: lift to top-of-file constants block |
| **A-13** | Verbose JSDoc on private helpers + over-justified rationale comments (8-12 line explanations for 1-line code) | `orchestrator.ts:822-833 (Review #20), 885-893 (Review C2)` | Public API needs JSDoc; private helpers don't. Distill rationale to 1-2 lines |
| **A-14** | Inconsistent error pathways — some throws, some return error-as-string, some warn-and-continue, some silently swallow | `agent-loop.ts:299, 323, 547, 616, 736-744` | Standardize via typed `Result<T, AgentLoopError>` or consistent throw + caller catch |
| **A-15** | Regex-based intent inference (preferences / decisions / corrections / structured findings) | `orchestrator.ts:835-1027` | SOTA pattern (Claude Code, Hermes): LLM-based extraction via small "memory writer" call with structured-output schema. Real engineering, post-launch. See PR-H |
---
## 1. Per-file findings (top 3 files; remaining 108 are healthy or out-of-scope for polish)
### `agent-loop.ts` (681 LOC)
```
agent-loop.ts:142 runAgentLoop is 540 lines — should be ~250 after A-3..A-6 extractions
agent-loop.ts:178 console.warn for allowedSources policy gap — typescript rule violation; route through logger
agent-loop.ts:242-263 8 scattered let vars (A-8) — typed LoopState would make invariants explicit
agent-loop.ts:265 outer for-loop body is 416 lines (turn loop); should be 80-100 once SSE + tool-exec + gates extract
agent-loop.ts:287-294 fetch call inline — extract to llmCall(litellmUrl, body, fetchFn) for testability
agent-loop.ts:296-323 retry policy inline + duplicated across 429 and 5xx branches (A-6)
agent-loop.ts:333-420 ~90L of SSE parsing (A-3) — lib/sse-parser.ts as standalone module
agent-loop.ts:362 let chunk: any — narrow via shared ChatCompletionChunk type
agent-loop.ts:423 const data: any — same; introduce ChatCompletionResponse type
agent-loop.ts:459-507 D1 + D3 gates inline in no-tool-calls branch (A-5) — extract to loop-gates.ts
agent-loop.ts:539-669 ~130L tool-exec sub-loop (A-4) — extract to tool-executor.ts as middleware chain:
pre:tool hook → governance → pre:memory-write → guard → execute → sanitize →
onToolResult → post:memory-write → post:tool
agent-loop.ts:573 result init to '' "defends against future refactors that slip in an early continue" —
the kind of defensive code that signals A-4 is overdue
agent-loop.ts:608 LoopGuard called via positional check(name, args) — fine, but the result/error
branches at 609-631 are a small ladder that belongs in tool-executor.ts
agent-loop.ts:632-641 scanForInjection ordering rationale (Review C2) is sound but the 8-line comment
block exceeds the 2-line CLAUDE.md §3.7 budget — distill (A-13)
```
### `orchestrator.ts` (1048 LOC)
```
orchestrator.ts:112-181 Orchestrator constructor + 6-layer initialization — fine, but the class
is then 900L of mixed responsibilities (A-9)
orchestrator.ts:223-247 getMemoryStats does 6 COUNT(*) queries — comment defends not caching ("once
per user turn, not once per LLM iteration"). Reasonable today; revisit if
personal mind exceeds ~500k frames
orchestrator.ts:254-278 fetchRecentFrames (M17) extracted — GOOD example of the pattern to apply
elsewhere in the class
orchestrator.ts:284-364 loadRecentContext returns string; extract to context-loader.ts (~80L)
orchestrator.ts:380-445 loadRecentContextFrames returns typed data; same extraction (~65L)
orchestrator.ts:451-465 cachedSection / uncachedSection helpers — fine, keep
orchestrator.ts:467-507 buildSystemPrompt — already clean after the section-caching refactor (good)
orchestrator.ts:517-561 buildAssembledPrompt — async, calls PromptAssembler; clean
orchestrator.ts:583-745 recallMemory is 162 lines — has 2 branches (catch-up SQL vs semantic),
filtering (R2 sign gate + score floor), formatting, injection scan,
turnId logging. Split the catch-up branch into its own private method;
drop recallMemory to ~80L
orchestrator.ts:593-598 12 catch-up regex patterns inline (A-7) — lift to module-level RECALL_PATTERNS
orchestrator.ts:753-1029 autoSaveFromExchange is 277 lines (A-2) — THE PRIORITY EXTRACTION.
Lift verbatim to pattern-write-back.ts as runAutoSavePatterns(orchestrator, userMsg, assistantMsg)
The function is structurally coherent: 4 pattern groups (preference,
style, decision-with-bilateral-consent, correction) + structured extraction (F29 a/b/c/d).
orchestrator.ts:835-846 prefPatterns array inline (A-7)
orchestrator.ts:894-902 decisionPatterns array inline (A-7)
orchestrator.ts:861-867 styleSignals array inline (A-7)
orchestrator.ts:822-833 Review #20 multi-line rationale (A-13) — distill
orchestrator.ts:885-893 Review C2 multi-line rationale (A-13) — distill
orchestrator.ts:944-962 hasStructuredFindings detection — fragile string-matching ("##" + URL pattern).
When the user gets to PR-H (LLM-based extractor), this is the strongest
candidate to lift out first because the rules are most ambiguous
```
### `behavioral-spec.ts` (449 LOC)
```
behavioral-spec.ts:18-449 v3.0, 5 named sections, COMPACTION_PROMPT export — CLEAN.
No polish opportunity. Recently refactored per CLAUDE.md §10 sprint status.
```
---
## 2. Suggested PR breakdown (Claude-Code/Hermes-shaped target)
| PR | Title | Files | LOC moved | Risk | Order |
|---|---|---|---|---|---|
| **PR-A** | `refactor(agent): extract autoSaveFromExchange → pattern-write-back.ts` | new `pattern-write-back.ts`, slim `orchestrator.ts` to ~770L | ~280 | Low (verbatim lift; same Orchestrator entry-point keeps it as `this.autoSaveFromExchange = (u, a) => runPatternWriteBack(this, u, a)`) | **First — highest leverage** |
| PR-B | `refactor(agent): extract SSE parser → lib/sse-parser.ts` | new `lib/sse-parser.ts`, slim agent-loop by ~90L | ~90 | Low (pure function, table-testable) | Second |
| PR-C | `refactor(agent): extract tool-call executor → lib/tool-executor.ts (middleware chain)` | new `lib/tool-executor.ts`, slim agent-loop by ~130L | ~150 | Medium (touches hook ordering — Review C2 invariant must be preserved + asserted via test) | Third |
| PR-D | `refactor(agent): extract D1/D3 gates → lib/loop-gates.ts` | new `lib/loop-gates.ts`, slim agent-loop by ~50L | ~80 | Low (gates are already isolated logic blocks) | Fourth |
| PR-E | `refactor(agent): typed LoopState + retry policy module` | new `lib/loop-state.ts`, new `lib/retry-policy.ts`, slim agent-loop by ~40L | ~120 | Low (mechanical) | Fifth |
| PR-F | `refactor(agent): extract context loaders → context-loader.ts` | new `context-loader.ts`, slim orchestrator by ~150L | ~155 | Low (the two `loadRecentContext*` methods are coherent units) | Sixth |
| PR-G | `chore(agent): collapse comment archaeology + remove `any` types` | both files | -200 LOC noise, +5 types | Low | Seventh |
| PR-H | `feat(agent): replace regex pattern extraction with LLM-based memory writer` | `pattern-write-back.ts`, new `memory-writer.ts` | ~-100, +200 | **High — real engineering**, post-launch | Deferred |
**End state (after PR-A..PR-G, ~5-7 working days):**
- `agent-loop.ts` ~ 250 LOC (vs 681 today)
- `orchestrator.ts` ~ 600 LOC (vs 1048 today)
- 6 new focused modules each ~80-280 LOC, individually testable
- Zero behavior change through PR-G (mechanical refactor)
- PR-H is a behavior change and gates on Marko's post-launch greenlight
---
## 3. Recommended Phase 1 — start here
**PR-A: Extract `autoSaveFromExchange` → `pattern-write-back.ts`.**
Why this first:
1. **Largest single extraction** — 277 LOC out of Orchestrator in one focused commit
2. **Lowest risk** — the function takes `(userMsg, assistantMsg)` and returns `string[]`; pure transform of two strings against the orchestrator's frame stores
3. **Coherent boundary** — the function already operates as a unit with a `save()` closure; lift the closure as a parameter
4. **Zero behavior change** — Orchestrator keeps its public API; the method body becomes a 1-line delegation
5. **Sets the pattern** — establishes the simplification model the rest of the PR sequence follows
**Verification gate before claiming done:**
```bash
npx tsc --noEmit --project packages/agent/tsconfig.json
npm run test -- --run packages/agent
npm run lint
```
All three must be green. Any test that imported `Orchestrator.autoSaveFromExchange` directly continues to work because the method is preserved as a thin delegation.
---
## 4. Risk + rollback
**Risk profile:**
- PR-A through PR-G are mechanical refactors. They preserve the function call shapes that external callers (server routes, tests) depend on.
- The single non-trivial invariant is **PR-C's hook ordering** (`pre:tool → governance → pre:memory-write → guard → execute → sanitize → post:memory-write → post:tool`). Review C2 documents why: sanitize must happen BEFORE post-hooks and `onToolResult` callback. Encode this as an assertion test before merging PR-C.
- PR-H is the only PR that changes behavior. It must run side-by-side against the regex extractor for at least 2 weeks of real harvest data before swapping, with an explicit comparison report.
**Rollback:**
```bash
# Per-PR — each PR is one atomic commit. Revert by SHA.
git revert <PR-A-sha>
# Whole-arc — checkpoint tag before PR-A:
git tag checkpoint/pre-agent-core-polish-2026-05-27
git push --tags
```
---
## 5. Out-of-scope for this review
- **`retrieval-agent-loop.ts` (973L)** — separate loop pattern for structured-action retrieval; its own polish review when needed
- **`system-tools.ts` (992L)** — tool definitions are necessarily long; not god-function territory
- **`persona-data.ts` (965L)** — declarative persona array; data, not logic
- **`evolve-schema.ts` (852L)** — self-evolution subsystem; its own audit when the polish here lands
- **The 100 other files** — most are 50-450 LOC focused modules. They look healthy on size scan.
---
Generated by manual review against HEAD `819571d` on 2026-05-27. Comparison references: Claude Code's tool-result-injection + auto-compact loop; Hermes' minimal-state ReAct (Pillar-1 benchmark target).