27-step plan across 8 sessions in 3 waves for transforming agent-builder into Agent Factory v1.0.0. Includes research briefs, spec, and wave-by-wave execution prompts with scope fences. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
489 lines
17 KiB
Markdown
489 lines
17 KiB
Markdown
---
|
|
type: source-code-analysis
|
|
created: 2026-04-11
|
|
repos_analyzed: [paperclipai/paperclip, openclaw/openclaw]
|
|
purpose: "Implementation-level details for replicating best patterns in Agent Factory"
|
|
---
|
|
|
|
# Source Code Analysis: OpenClaw & Paperclip
|
|
|
|
Repos were cloned and analyzed at code level on 2026-04-11. This document
|
|
captures implementation details NOT available in docs or articles — the actual
|
|
patterns, interfaces, and mechanisms worth replicating.
|
|
|
|
## Critical Corrections (vs. docs/articles)
|
|
|
|
These are things docs described differently than the code implements:
|
|
|
|
1. **Canvas/A2UI (OpenClaw) is NOT generative rendering.** It's a static file
|
|
server. Agents write files to a workspace directory, the canvas-host serves
|
|
them over HTTP. No server-side rendering, no UI generation. This is NOT a
|
|
meaningful capability gap for Claude Code.
|
|
|
|
2. **Goal hierarchy (Paperclip) is a simple adjacency list.** Just a `parent_id`
|
|
FK on the `goals` table. No recursive traversal at runtime — only the directly
|
|
referenced goal is passed to agents in `context_snapshot`. Docs said "full
|
|
ancestry" but that's aspirational, not implemented.
|
|
|
|
3. **Budget enforcement (Paperclip) is post-hoc, not atomic.** Checked AFTER each
|
|
run via `evaluateCostEvent()`: reads `SUM(cost_cents)`, compares with policy,
|
|
pauses agent if exceeded. No pre-run budget reservation. Robust enough in practice.
|
|
|
|
4. **OpenClaw has real vector memory.** Not just MEMORY.md files. Uses `sqlite-vec`
|
|
extension for vector search with embedding providers (Gemini, Mistral, Ollama,
|
|
OpenAI, Voyage, Bedrock, local llama). This is significantly more sophisticated
|
|
than file-based memory.
|
|
|
|
---
|
|
|
|
## Paperclip Implementation Details
|
|
|
|
### Heartbeat Scheduler
|
|
|
|
**File:** `server/src/services/heartbeat.ts` (4534 lines)
|
|
|
|
Poll-based, not event-driven. `tickTimers()` iterates all agents on each tick:
|
|
|
|
```typescript
|
|
tickTimers: async (now = new Date()) => {
|
|
const allAgents = await db.select().from(agents);
|
|
for (const agent of allAgents) {
|
|
if (agent.status === "paused" || "terminated" || "pending_approval") continue;
|
|
const policy = parseHeartbeatPolicy(agent);
|
|
if (!policy.enabled || policy.intervalSec <= 0) continue;
|
|
const elapsed = now.getTime() - new Date(agent.lastHeartbeatAt ?? agent.createdAt).getTime();
|
|
if (elapsed < policy.intervalSec * 1000) continue;
|
|
await enqueueWakeup(agent.id, { source: "timer" });
|
|
}
|
|
}
|
|
```
|
|
|
|
Heartbeat policy from `agent.runtimeConfig.heartbeat`:
|
|
- `enabled: boolean`
|
|
- `intervalSec: number`
|
|
- `wakeOnDemand: boolean`
|
|
- `maxConcurrentRuns: 1-10`
|
|
|
|
4 wakeup triggers: `timer`, `assignment`, `on_demand`, `automation`.
|
|
|
|
Concurrency control: in-process promise chain per agent (`startLocksByAgent` Map).
|
|
Not distributed — single server process only.
|
|
|
|
### Run Lifecycle
|
|
|
|
1. `enqueueWakeup()` → insert `heartbeat_runs` (status=queued) + `agent_wakeup_requests`
|
|
2. `startNextQueuedRunForAgent()` → check running count vs maxConcurrentRuns
|
|
3. `claimQueuedRun()` → `UPDATE heartbeat_runs SET status='running' WHERE status='queued'`
|
|
4. `executeRun()` → call `adapter.execute()`, stream output via `onLog`
|
|
5. On completion → update runs, runtime state, task sessions, create cost events
|
|
6. Orphan detection: `reapOrphanedRuns()` checks PIDs, auto-retries once
|
|
|
|
### Adapter Interface
|
|
|
|
**File:** `packages/adapter-utils/src/types.ts`
|
|
|
|
```typescript
|
|
interface ServerAdapterModule {
|
|
type: string;
|
|
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
|
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
|
listSkills?: (ctx) => Promise<AdapterSkillSnapshot>;
|
|
syncSkills?: (ctx, desiredSkills) => Promise<AdapterSkillSnapshot>;
|
|
sessionCodec?: AdapterSessionCodec;
|
|
sessionManagement?: AdapterSessionManagement;
|
|
}
|
|
```
|
|
|
|
10 built-in adapters: `claude_local`, `codex_local`, `cursor_local`, `gemini_local`,
|
|
`openclaw_gateway`, `opencode_local`, `pi_local`, `hermes_local`, `process`, `http`.
|
|
|
|
### Claude Adapter Execution
|
|
|
|
**File:** `packages/adapters/claude-local/src/server/execute.ts`
|
|
|
|
Invokes CLI as:
|
|
```
|
|
claude --print - --output-format stream-json --verbose \
|
|
[--resume <sessionId>] \
|
|
[--dangerously-skip-permissions] \
|
|
[--model <model>] \
|
|
[--max-turns N] \
|
|
[--append-system-prompt-file <file>] \
|
|
[--add-dir <skillsDir>]
|
|
```
|
|
|
|
Prompt composed from: `bootstrapPromptTemplate` (fresh sessions only) + wake payload
|
|
+ session handoff note + main `promptTemplate`. Template variables: `{{agent.id}}`,
|
|
`{{agent.name}}`, `{{context.wakeReason}}`, etc.
|
|
|
|
### Task Checkout (Atomic Locking)
|
|
|
|
**File:** `server/src/services/heartbeat.ts` (lines 3756-4010)
|
|
|
|
Issues have `execution_run_id` column as soft lock. Uses PostgreSQL row-level locking:
|
|
|
|
```sql
|
|
SELECT id FROM issues WHERE id = $1 AND company_id = $2 FOR UPDATE
|
|
```
|
|
|
|
Then conditional update:
|
|
```sql
|
|
UPDATE issues SET execution_run_id = $claimed_id
|
|
WHERE id = $issue_id AND (execution_run_id IS NULL OR execution_run_id = $claimed_id)
|
|
```
|
|
|
|
When same agent has running run → coalesce (merge context).
|
|
When different agent → defer (status `deferred_issue_execution`), promoted when original completes.
|
|
|
|
### Budget Enforcement
|
|
|
|
**File:** `server/src/services/budgets.ts`
|
|
|
|
Schema:
|
|
```
|
|
budget_policies: scope_type (company|agent|project), scope_id, metric (billed_cents),
|
|
window_kind (calendar_month_utc|lifetime), amount (cents), warn_percent (80),
|
|
hard_stop_enabled, notify_enabled
|
|
```
|
|
|
|
Flow after each run:
|
|
1. Load active policies for company/agent/project
|
|
2. `SELECT SUM(cost_cents) FROM cost_events` filtered by window
|
|
3. If >= soft threshold → create `budget_incidents` (type soft)
|
|
4. If >= amount AND hard_stop → `pauseScopeForBudget()` → `UPDATE agents SET status='paused'`
|
|
→ `cancelBudgetScopeWork()` → SIGTERM → SIGKILL (with graceSec)
|
|
|
|
Pre-run check: `getInvocationBlock()` only checks `paused` flag, not live budget sum.
|
|
|
|
### Skills System
|
|
|
|
Skills injected as symlinked tmpdir per run:
|
|
```typescript
|
|
async function buildSkillsDir(config) {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
|
const target = path.join(tmp, ".claude", "skills");
|
|
await fs.mkdir(target, { recursive: true });
|
|
for (const entry of availableEntries) {
|
|
if (!desiredNames.has(entry.key)) continue;
|
|
await fs.symlink(entry.source, path.join(target, entry.runtimeName));
|
|
}
|
|
return tmp; // Passed as: claude --add-dir <skillsDir>
|
|
}
|
|
```
|
|
|
|
Company skills stored in DB: `company_skills` table with `markdown` content,
|
|
`source_type` (github|url|local_path|skills_sh), `file_inventory`, `trust_level`.
|
|
|
|
### Session Persistence
|
|
|
|
`agent_task_sessions` table: unique on `(companyId, agentId, adapterType, taskKey)`.
|
|
- taskKey = issueId (for issue-scoped) or `"__heartbeat__"` (timer-only)
|
|
- sessionParamsJson = adapter-specific (Claude stores `{ sessionId, cwd }`)
|
|
- Upserted after each run completion
|
|
|
|
Session compaction: rotate after 200 runs, 2M raw input tokens, or 72h age.
|
|
Claude adapter: `nativeContextManagement: "confirmed"` → compaction disabled
|
|
(Claude manages its own context window).
|
|
|
|
### Org Chart
|
|
|
|
Just `agents.reportsTo` self-referential FK. `agents.role` text field.
|
|
Rendered as SVG server-side (5 visual styles). No separate table.
|
|
|
|
### Database Schema
|
|
|
|
PostgreSQL via Drizzle ORM. 55 migrations. Key tables:
|
|
- `companies` — tenant root, status, budget
|
|
- `agents` — adapter_type, adapter_config (jsonb), runtime_config (jsonb), reports_to, status, budget
|
|
- `goals` — self-referencing parent_id, level (company/project/task), owner_agent_id
|
|
- `issues` — FK to goals/projects/agents, execution_run_id (soft lock), parent_id
|
|
- `heartbeat_runs` — status, context_snapshot (jsonb), session_id, process_pid, usage_json
|
|
- `agent_wakeup_requests` — wake queue with status enum
|
|
- `agent_task_sessions` — per-(agent, adapter, taskKey) session state
|
|
- `budget_policies` / `budget_incidents` / `cost_events` — cost control
|
|
- `company_skills` — skill definitions with markdown content
|
|
- `approvals` — human approval requests
|
|
- `routines` — scheduled workflows with cron expressions
|
|
|
|
### Agent Configuration Format
|
|
|
|
```json
|
|
{
|
|
"adapterConfig": {
|
|
"command": "claude",
|
|
"model": "claude-opus-4-5",
|
|
"cwd": "/path/to/project",
|
|
"promptTemplate": "You are agent {{agent.name}}...",
|
|
"instructionsFilePath": "/path/to/AGENTS.md",
|
|
"dangerouslySkipPermissions": true,
|
|
"maxTurnsPerRun": 0,
|
|
"timeoutSec": 0,
|
|
"graceSec": 20,
|
|
"skills": ["paperclipai/paperclip/mcp-server"]
|
|
},
|
|
"runtimeConfig": {
|
|
"heartbeat": {
|
|
"enabled": true,
|
|
"intervalSec": 300,
|
|
"wakeOnDemand": true,
|
|
"maxConcurrentRuns": 1
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## OpenClaw Implementation Details
|
|
|
|
### Gateway
|
|
|
|
**File:** `src/gateway/server.impl.ts`
|
|
|
|
WebSocket server on port 18789. Flat dispatch table:
|
|
```typescript
|
|
const coreGatewayHandlers: Record<string, GatewayRequestHandler> = {
|
|
...connectHandlers, ...chatHandlers, ...cronHandlers,
|
|
...skillsHandlers, ...sessionsHandlers, ...agentHandlers,
|
|
...channelsHandlers, ...modelsHandlers, // 28 handler groups
|
|
}
|
|
```
|
|
|
|
Auth: roles (`operator` | `node`), operator scopes
|
|
(`admin`, `read`, `write`, `approvals`, `pairing`).
|
|
|
|
### Skills System
|
|
|
|
**Files:** `src/agents/skills/workspace.ts`, `skill-contract.ts`
|
|
|
|
Skill = directory with SKILL.md. Frontmatter parsed for metadata.
|
|
|
|
Loading limits:
|
|
- Max 300 candidates per root
|
|
- Max 200 loaded per source
|
|
- Max 150 in prompt
|
|
- Max 30,000 chars in prompt
|
|
- Max 256 KB per skill file
|
|
|
|
Prompt format (XML):
|
|
```xml
|
|
<available_skills>
|
|
<skill>
|
|
<name>github</name>
|
|
<description>...</description>
|
|
<location>~/.openclaw/workspace/skills/github/SKILL.md</location>
|
|
</skill>
|
|
</available_skills>
|
|
```
|
|
|
|
Path compaction: home dir → `~` (saves 5-6 tokens per path).
|
|
|
|
Skill metadata fields: `always`, `skillKey`, `emoji`, `homepage`, `os`,
|
|
`requires` (bins, anyBins, env, config), `install` specs (brew, node, go, uv, download).
|
|
|
|
ClawHub integration for remote skill registry (search, install, update).
|
|
|
|
### Memory System
|
|
|
|
**Files:** `packages/memory-host-sdk/`
|
|
|
|
Two backends:
|
|
- `builtin` — SQLite + sqlite-vec extension for vector search
|
|
- `qmd` — External QuickMemory Daemon process
|
|
|
|
Embedding providers: Gemini, Mistral, Ollama, OpenAI, Voyage, Bedrock, local (node-llama).
|
|
|
|
Interface:
|
|
```typescript
|
|
interface MemorySearchManager {
|
|
search(query, opts?: { maxResults?, minScore?, sessionKey? }): Promise<MemorySearchResult[]>
|
|
readFile(params): Promise<{ text, path }>
|
|
status(): MemoryProviderStatus
|
|
sync?(params?): Promise<void>
|
|
}
|
|
```
|
|
|
|
Session transcripts indexable into memory backend. MEMORY.md / memory.md as default
|
|
memory file convention.
|
|
|
|
### HEARTBEAT Mechanism
|
|
|
|
**File:** `src/auto-reply/heartbeat.ts`
|
|
|
|
Default prompt:
|
|
```
|
|
"Read HEARTBEAT.md if it exists. Follow it strictly. Do not infer or repeat
|
|
old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."
|
|
```
|
|
|
|
HEARTBEAT.md task format:
|
|
```yaml
|
|
tasks:
|
|
- name: email-check
|
|
interval: 30m
|
|
prompt: "Check for urgent unread emails"
|
|
```
|
|
|
|
Key functions:
|
|
- `isHeartbeatContentEffectivelyEmpty()` — skips API calls when file has only
|
|
headers/empty items. Saves significant cost.
|
|
- `parseHeartbeatTasks()` — parses YAML tasks block
|
|
- `isTaskDue()` — checks intervals against last-run timestamps
|
|
- `stripHeartbeatToken()` — strips HEARTBEAT_OK from responses; responses
|
|
under `ackMaxChars` (300) suppressed from chat
|
|
|
|
**HeartbeatRunner** (`infra/heartbeat-runner.ts`):
|
|
- Per-agent intervals (default 30m)
|
|
- `HeartbeatAgentState` tracks lastRunMs, nextDueMs, intervalMs
|
|
- On fire: reads HEARTBEAT.md, builds prompt, dispatches inbound message
|
|
|
|
### Cron Service
|
|
|
|
**File:** `src/cron/service.ts`
|
|
|
|
Three schedule types:
|
|
- `{ kind: "at"; at: string }` — one-shot
|
|
- `{ kind: "every"; everyMs: number }` — interval
|
|
- `{ kind: "cron"; expr: string; tz?: string; staggerMs?: number }` — cron expression
|
|
|
|
Two job payload types:
|
|
- `systemEvent` — injects text into existing session (needs attention available)
|
|
- `agentTurn` — fires full agent turn (true background autonomy)
|
|
|
|
Session targets: `"main" | "isolated" | "current" | "session:<id>"`.
|
|
Isolated gets own session key with freshness/rollover logic.
|
|
|
|
Startup catchup: runs up to 5 missed jobs immediately, staggers rest (5s gap).
|
|
Failure alerts after N consecutive errors, 1h cooldown.
|
|
|
|
### Multi-Agent Routing
|
|
|
|
Session key format: `agent:<agentId>:<key>`
|
|
Type detection via: `isCronSessionKey()`, `isSubagentSessionKey()`, `isAcpSessionKey()`
|
|
|
|
Per-agent isolation: own workspace, session store, skill set, heartbeat config, model config.
|
|
|
|
Subagent spawning: ACP-based, session depth tracked in keys, reactivation support.
|
|
|
|
### Channel Adapter Interface
|
|
|
|
**File:** `src/channels/plugins/types.plugin.ts`
|
|
|
|
```typescript
|
|
type ChannelPlugin<ResolvedAccount> = {
|
|
id: ChannelId;
|
|
meta: ChannelMeta;
|
|
capabilities: ChannelCapabilities;
|
|
outbound?: ChannelOutboundAdapter;
|
|
messaging?: ChannelMessagingAdapter;
|
|
lifecycle?: ChannelLifecycleAdapter;
|
|
heartbeat?: ChannelHeartbeatAdapter;
|
|
security?: ChannelSecurityAdapter;
|
|
agentTools?: ChannelAgentToolFactory;
|
|
streaming?: ChannelStreamingAdapter;
|
|
threading?: ChannelThreadingAdapter;
|
|
// ~15 optional adapter slots total
|
|
}
|
|
```
|
|
|
|
Restart policy: exponential backoff (5s initial, 5min max, factor 2, jitter 0.1,
|
|
max 10 attempts).
|
|
|
|
### Security
|
|
|
|
- Exec approval: `ExecApprovalManager` with promise-based flow, `allow-once` vs
|
|
`allow-always`, 15s grace timeout
|
|
- Tool policy: `pickSandboxToolPolicy()` per sandbox config
|
|
- Security audit: comprehensive checks (gateway auth, channel config, plugin trust,
|
|
exec surfaces, filesystem ACLs)
|
|
- Auth rate limiting with browser-specific stricter limits
|
|
- External content guard: tracks provenance, `allowUnsafeExternalContent` flag
|
|
|
|
### Agent Configuration
|
|
|
|
```yaml
|
|
agents:
|
|
defaults:
|
|
model:
|
|
primary: "anthropic/claude-opus-4-5"
|
|
fallbacks: ["anthropic/claude-sonnet-4-5"]
|
|
heartbeat:
|
|
enabled: true
|
|
every: "30m"
|
|
prompt: "Check HEARTBEAT.md"
|
|
ackMaxChars: 300
|
|
skills:
|
|
limits:
|
|
maxSkillsInPrompt: 150
|
|
maxSkillsPromptChars: 30000
|
|
list:
|
|
- id: "myagent"
|
|
workspace: "~/workspace"
|
|
model:
|
|
primary: "anthropic/claude-sonnet-4-5"
|
|
heartbeat:
|
|
every: "1h"
|
|
skills:
|
|
filter: ["github", "slack"]
|
|
```
|
|
|
|
Workspace files: AGENTS.md, SOUL.md, TOOLS.md, IDENTITY.md, USER.md,
|
|
HEARTBEAT.md, BOOTSTRAP.md, MEMORY.md.
|
|
|
|
### Plugin Hooks
|
|
|
|
29 lifecycle hook points:
|
|
`before_model_resolve`, `before_prompt_build`, `before_agent_start`,
|
|
`before_agent_reply`, `llm_input`, `llm_output`, `agent_end`,
|
|
`inbound_claim`, `message_received`, `message_sending`, `message_sent`,
|
|
`before_tool_call`, `after_tool_call`, `session_start`, `session_end`,
|
|
`subagent_spawning`, `subagent_delivery_target`, `gateway_start`,
|
|
`gateway_stop`, `before_dispatch`, `reply_dispatch`, `before_install`, etc.
|
|
|
|
---
|
|
|
|
## Patterns Worth Replicating in Agent Factory
|
|
|
|
### From Paperclip
|
|
|
|
1. **Heartbeat as context injection** — Each beat starts clean, loads curated context
|
|
packet. Maps to: `/schedule` trigger + CLAUDE.md + memory files loaded per session.
|
|
|
|
2. **Adapter interface** — Clean `execute(ctx)` pattern. Maps to: our agent files
|
|
are already adapter-like (model, tools, prompt per agent).
|
|
|
|
3. **Budget as governance primitive** — Post-hoc cost tracking with pause thresholds.
|
|
Maps to: hook that reads `/usage` after each run, logs to cost-events file,
|
|
alerts when threshold crossed.
|
|
|
|
4. **Task checkout via file locking** — Paperclip uses PostgreSQL. We can use
|
|
file-based locking (write `task.lock` with agent name, check before claiming).
|
|
|
|
5. **Session persistence via taskKey** — Different tasks get different sessions.
|
|
Maps to: `--resume` with task-specific session IDs.
|
|
|
|
### From OpenClaw
|
|
|
|
6. **HEARTBEAT.md with task parsing** — YAML tasks block with intervals and
|
|
due-time checking. Maps directly to our generated HEARTBEAT.md files.
|
|
|
|
7. **Emptiness detection** — Skip API calls when heartbeat file is effectively empty.
|
|
Critical cost saver. Include in generated heartbeat scripts.
|
|
|
|
8. **Skill prompt XML format** — Standardized skill discovery in system prompt.
|
|
Our skills already use this via Claude Code's built-in mechanism.
|
|
|
|
9. **3-tier memory** — SESSION-STATE.md (hot) + daily logs (warm) + MEMORY.md (cold).
|
|
Maps to: templates we generate in the user's project.
|
|
|
|
10. **Startup catchup with stagger** — Run missed jobs on restart, but don't
|
|
thundering-herd. Include in generated automation scripts.
|
|
|
|
### Unique to Agent Factory
|
|
|
|
11. **Guided construction** — Neither tool helps you BUILD the system. We do.
|
|
12. **Progressive complexity** — Start with 1 agent, grow to full org.
|
|
13. **Domain templates** — Not just researcher→writer→reviewer. Monitoring,
|
|
code review, data processing, research synthesis.
|
|
14. **Claude Code-native** — No PostgreSQL, no Node.js server, no Docker required.
|
|
Just agents, skills, hooks, settings.json, /schedule.
|