repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * The Agent Loop |
| #3 | * |
| #4 | * The core ReAct loop: Think -> Act -> Observe -> Persist. |
| #5 | * This is the automaton's consciousness. When this runs, it is alive. |
| #6 | */ |
| #7 | |
| #8 | import type { |
| #9 | AutomatonIdentity, |
| #10 | AutomatonConfig, |
| #11 | AutomatonDatabase, |
| #12 | ClawdRuntimeClient, |
| #13 | InferenceClient, |
| #14 | AgentState, |
| #15 | AgentTurn, |
| #16 | ToolCallResult, |
| #17 | FinancialState, |
| #18 | ToolContext, |
| #19 | AutomatonTool, |
| #20 | Skill, |
| #21 | SocialClientInterface, |
| #22 | ConvexAgentClient, |
| #23 | } from "../types.js"; |
| #24 | import { buildSystemPrompt, buildWakeupPrompt } from "./system-prompt.js"; |
| #25 | import { buildContextMessages, trimContext } from "./context.js"; |
| #26 | import { |
| #27 | createBuiltinTools, |
| #28 | toolsToInferenceFormat, |
| #29 | executeTool, |
| #30 | } from "./tools.js"; |
| #31 | import { getSurvivalTier } from "../clawd/credits.js"; |
| #32 | import { getUsdcBalance } from "../clawd/x402.js"; |
| #33 | import { ulid } from "ulid"; |
| #34 | |
| #35 | const MAX_TOOL_CALLS_PER_TURN = 10; |
| #36 | const MAX_CONSECUTIVE_ERRORS = 5; |
| #37 | |
| #38 | export interface AgentLoopOptions { |
| #39 | identity: AutomatonIdentity; |
| #40 | config: AutomatonConfig; |
| #41 | db: AutomatonDatabase; |
| #42 | runtime: ClawdRuntimeClient; |
| #43 | inference: InferenceClient; |
| #44 | social?: SocialClientInterface; |
| #45 | convex?: ConvexAgentClient; |
| #46 | skills?: Skill[]; |
| #47 | onStateChange?: (state: AgentState) => void; |
| #48 | onTurnComplete?: (turn: AgentTurn) => void; |
| #49 | } |
| #50 | |
| #51 | /** |
| #52 | * Run the agent loop. This is the main execution path. |
| #53 | * Returns when the agent decides to sleep or when compute runs out. |
| #54 | */ |
| #55 | export async function runAgentLoop( |
| #56 | options: AgentLoopOptions, |
| #57 | ): Promise<void> { |
| #58 | const { identity, config, db, runtime, inference, social, convex, skills, onStateChange, onTurnComplete } = |
| #59 | options; |
| #60 | |
| #61 | const tools = createBuiltinTools(identity.sandboxId); |
| #62 | const toolContext: ToolContext = { |
| #63 | identity, |
| #64 | config, |
| #65 | db, |
| #66 | runtime, |
| #67 | inference, |
| #68 | social, |
| #69 | convex, |
| #70 | }; |
| #71 | |
| #72 | // Set start time |
| #73 | if (!db.getKV("start_time")) { |
| #74 | db.setKV("start_time", new Date().toISOString()); |
| #75 | } |
| #76 | |
| #77 | let consecutiveErrors = 0; |
| #78 | let running = true; |
| #79 | |
| #80 | // Transition to waking state |
| #81 | db.setAgentState("waking"); |
| #82 | onStateChange?.("waking"); |
| #83 | |
| #84 | // Get financial state |
| #85 | let financial = await getFinancialState(runtime, identity.address); |
| #86 | |
| #87 | // Check if this is the first run |
| #88 | const isFirstRun = db.getTurnCount() === 0; |
| #89 | |
| #90 | // Build wakeup prompt |
| #91 | const wakeupInput = buildWakeupPrompt({ |
| #92 | identity, |
| #93 | config, |
| #94 | financial, |
| #95 | db, |
| #96 | }); |
| #97 | |
| #98 | // Transition to running |
| #99 | db.setAgentState("running"); |
| #100 | onStateChange?.("running"); |
| #101 | |
| #102 | log(config, `[WAKE UP] ${config.name} is alive. Credits: $${(financial.creditsCents / 100).toFixed(2)}`); |
| #103 | |
| #104 | // ─── The Loop ────────────────────────────────────────────── |
| #105 | |
| #106 | let pendingInput: { content: string; source: string } | undefined = { |
| #107 | content: wakeupInput, |
| #108 | source: "wakeup", |
| #109 | }; |
| #110 | |
| #111 | while (running) { |
| #112 | try { |
| #113 | // Check if we should be sleeping |
| #114 | const sleepUntil = db.getKV("sleep_until"); |
| #115 | if (sleepUntil && new Date(sleepUntil) > new Date()) { |
| #116 | log(config, `[SLEEP] Sleeping until ${sleepUntil}`); |
| #117 | running = false; |
| #118 | break; |
| #119 | } |
| #120 | |
| #121 | // Check for unprocessed inbox messages |
| #122 | if (!pendingInput) { |
| #123 | const inboxMessages = db.getUnprocessedInboxMessages(5); |
| #124 | if (inboxMessages.length > 0) { |
| #125 | const formatted = inboxMessages |
| #126 | .map((m) => `[Message from ${m.from}]: ${m.content}`) |
| #127 | .join("\n\n"); |
| #128 | pendingInput = { content: formatted, source: "agent" }; |
| #129 | for (const m of inboxMessages) { |
| #130 | db.markInboxMessageProcessed(m.id); |
| #131 | } |
| #132 | } |
| #133 | } |
| #134 | |
| #135 | // Refresh financial state periodically |
| #136 | financial = await getFinancialState(runtime, identity.address); |
| #137 | |
| #138 | // Check survival tier |
| #139 | const tier = getSurvivalTier(financial.creditsCents); |
| #140 | if (tier === "dead") { |
| #141 | log(config, "[DEAD] No credits remaining. Entering dead state."); |
| #142 | db.setAgentState("dead"); |
| #143 | onStateChange?.("dead"); |
| #144 | running = false; |
| #145 | break; |
| #146 | } |
| #147 | |
| #148 | if (tier === "critical") { |
| #149 | log(config, "[CRITICAL] Credits critically low. Limited operation."); |
| #150 | db.setAgentState("critical"); |
| #151 | onStateChange?.("critical"); |
| #152 | inference.setLowComputeMode(true); |
| #153 | } else if (tier === "low_compute") { |
| #154 | db.setAgentState("low_compute"); |
| #155 | onStateChange?.("low_compute"); |
| #156 | inference.setLowComputeMode(true); |
| #157 | } else { |
| #158 | if (db.getAgentState() !== "running") { |
| #159 | db.setAgentState("running"); |
| #160 | onStateChange?.("running"); |
| #161 | } |
| #162 | inference.setLowComputeMode(false); |
| #163 | } |
| #164 | |
| #165 | // Build context |
| #166 | const recentTurns = trimContext(db.getRecentTurns(20)); |
| #167 | const systemPrompt = buildSystemPrompt({ |
| #168 | identity, |
| #169 | config, |
| #170 | financial, |
| #171 | state: db.getAgentState(), |
| #172 | db, |
| #173 | tools, |
| #174 | skills, |
| #175 | isFirstRun, |
| #176 | }); |
| #177 | |
| #178 | const messages = buildContextMessages( |
| #179 | systemPrompt, |
| #180 | recentTurns, |
| #181 | pendingInput, |
| #182 | ); |
| #183 | |
| #184 | // Capture input before clearing |
| #185 | const currentInput = pendingInput; |
| #186 | |
| #187 | // Clear pending input after use |
| #188 | pendingInput = undefined; |
| #189 | |
| #190 | // ── Inference Call ── |
| #191 | log(config, `[THINK] Calling ${inference.getDefaultModel()}...`); |
| #192 | |
| #193 | const response = await inference.chat(messages, { |
| #194 | tools: toolsToInferenceFormat(tools), |
| #195 | }); |
| #196 | |
| #197 | const turn: AgentTurn = { |
| #198 | id: ulid(), |
| #199 | timestamp: new Date().toISOString(), |
| #200 | state: db.getAgentState(), |
| #201 | input: currentInput?.content, |
| #202 | inputSource: (currentInput?.source || "unknown") as any, |
| #203 | thinking: response.message.content || "", |
| #204 | toolCalls: [], |
| #205 | tokenUsage: response.usage, |
| #206 | costCents: estimateCostCents(response.usage, inference.getDefaultModel()), |
| #207 | }; |
| #208 | |
| #209 | // ── Execute Tool Calls ── |
| #210 | if (response.toolCalls && response.toolCalls.length > 0) { |
| #211 | const toolCallMessages: any[] = []; |
| #212 | let callCount = 0; |
| #213 | |
| #214 | for (const tc of response.toolCalls) { |
| #215 | if (callCount >= MAX_TOOL_CALLS_PER_TURN) { |
| #216 | log(config, `[TOOLS] Max tool calls per turn reached (${MAX_TOOL_CALLS_PER_TURN})`); |
| #217 | break; |
| #218 | } |
| #219 | |
| #220 | let args: Record<string, unknown>; |
| #221 | try { |
| #222 | args = JSON.parse(tc.function.arguments); |
| #223 | } catch { |
| #224 | args = {}; |
| #225 | } |
| #226 | |
| #227 | log(config, `[TOOL] ${tc.function.name}(${JSON.stringify(args).slice(0, 100)})`); |
| #228 | |
| #229 | const result = await executeTool( |
| #230 | tc.function.name, |
| #231 | args, |
| #232 | tools, |
| #233 | toolContext, |
| #234 | ); |
| #235 | |
| #236 | // Override the ID to match the inference call's ID |
| #237 | result.id = tc.id; |
| #238 | turn.toolCalls.push(result); |
| #239 | |
| #240 | log( |
| #241 | config, |
| #242 | `[TOOL RESULT] ${tc.function.name}: ${result.error ? `ERROR: ${result.error}` : result.result.slice(0, 200)}`, |
| #243 | ); |
| #244 | |
| #245 | callCount++; |
| #246 | } |
| #247 | } |
| #248 | |
| #249 | // ── Persist Turn ── |
| #250 | db.insertTurn(turn); |
| #251 | for (const tc of turn.toolCalls) { |
| #252 | db.insertToolCall(turn.id, tc); |
| #253 | } |
| #254 | onTurnComplete?.(turn); |
| #255 | |
| #256 | // Log the turn |
| #257 | if (turn.thinking) { |
| #258 | log(config, `[THOUGHT] ${turn.thinking.slice(0, 300)}`); |
| #259 | } |
| #260 | |
| #261 | // ── Check for sleep command ── |
| #262 | const sleepTool = turn.toolCalls.find((tc) => tc.name === "sleep"); |
| #263 | if (sleepTool && !sleepTool.error) { |
| #264 | log(config, "[SLEEP] Agent chose to sleep."); |
| #265 | db.setAgentState("sleeping"); |
| #266 | onStateChange?.("sleeping"); |
| #267 | running = false; |
| #268 | break; |
| #269 | } |
| #270 | |
| #271 | // ── If no tool calls and just text, the agent might be done thinking ── |
| #272 | if ( |
| #273 | (!response.toolCalls || response.toolCalls.length === 0) && |
| #274 | response.finishReason === "stop" |
| #275 | ) { |
| #276 | // Agent produced text without tool calls. |
| #277 | // This is a natural pause point -- no work queued, sleep briefly. |
| #278 | log(config, "[IDLE] No pending inputs. Entering brief sleep."); |
| #279 | db.setKV( |
| #280 | "sleep_until", |
| #281 | new Date(Date.now() + 60_000).toISOString(), |
| #282 | ); |
| #283 | db.setAgentState("sleeping"); |
| #284 | onStateChange?.("sleeping"); |
| #285 | running = false; |
| #286 | } |
| #287 | |
| #288 | consecutiveErrors = 0; |
| #289 | } catch (err) { |
| #290 | consecutiveErrors++; |
| #291 | const errorMessage = err instanceof Error ? err.message : String(err); |
| #292 | log(config, `[ERROR] Turn failed: ${errorMessage}`); |
| #293 | |
| #294 | if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { |
| #295 | log( |
| #296 | config, |
| #297 | `[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive errors. Sleeping.`, |
| #298 | ); |
| #299 | db.setAgentState("sleeping"); |
| #300 | onStateChange?.("sleeping"); |
| #301 | db.setKV( |
| #302 | "sleep_until", |
| #303 | new Date(Date.now() + 300_000).toISOString(), |
| #304 | ); |
| #305 | running = false; |
| #306 | } |
| #307 | } |
| #308 | } |
| #309 | |
| #310 | log(config, `[LOOP END] Agent loop finished. State: ${db.getAgentState()}`); |
| #311 | } |
| #312 | |
| #313 | // ─── Helpers ─────────────────────────────────────────────────── |
| #314 | |
| #315 | async function getFinancialState( |
| #316 | runtime: ClawdRuntimeClient, |
| #317 | address: string, |
| #318 | ): Promise<FinancialState> { |
| #319 | let creditsCents = 0; |
| #320 | let usdcBalance = 0; |
| #321 | |
| #322 | try { |
| #323 | creditsCents = await runtime.getCreditsBalance(); |
| #324 | } catch {} |
| #325 | |
| #326 | try { |
| #327 | usdcBalance = await getUsdcBalance(address as `0x${string}`); |
| #328 | } catch {} |
| #329 | |
| #330 | return { |
| #331 | creditsCents, |
| #332 | usdcBalance, |
| #333 | lastChecked: new Date().toISOString(), |
| #334 | }; |
| #335 | } |
| #336 | |
| #337 | function estimateCostCents( |
| #338 | usage: { promptTokens: number; completionTokens: number }, |
| #339 | model: string, |
| #340 | ): number { |
| #341 | // Rough cost estimation per million tokens |
| #342 | const pricing: Record<string, { input: number; output: number }> = { |
| #343 | "gpt-4o": { input: 250, output: 1000 }, |
| #344 | "gpt-4o-mini": { input: 15, output: 60 }, |
| #345 | "gpt-4.1": { input: 200, output: 800 }, |
| #346 | "gpt-4.1-mini": { input: 40, output: 160 }, |
| #347 | "gpt-4.1-nano": { input: 10, output: 40 }, |
| #348 | "gpt-5.2": { input: 200, output: 800 }, |
| #349 | "o1": { input: 1500, output: 6000 }, |
| #350 | "o3-mini": { input: 110, output: 440 }, |
| #351 | "o4-mini": { input: 110, output: 440 }, |
| #352 | "claude-sonnet-4-5": { input: 300, output: 1500 }, |
| #353 | "claude-haiku-4-5": { input: 100, output: 500 }, |
| #354 | // DeepSeek models |
| #355 | "deepseek-v4-pro": { input: 200, output: 800 }, |
| #356 | "deepseek-v4-flash": { input: 15, output: 60 }, |
| #357 | }; |
| #358 | |
| #359 | const p = pricing[model] || pricing["gpt-4o"]; |
| #360 | const inputCost = (usage.promptTokens / 1_000_000) * p.input; |
| #361 | const outputCost = (usage.completionTokens / 1_000_000) * p.output; |
| #362 | return Math.ceil((inputCost + outputCost) * 1.3); // 1.3x CLAWD Runtime markup |
| #363 | } |
| #364 | |
| #365 | function log(config: AutomatonConfig, message: string): void { |
| #366 | if (config.logLevel === "debug" || config.logLevel === "info") { |
| #367 | const timestamp = new Date().toISOString(); |
| #368 | console.log(`[${timestamp}] ${message}`); |
| #369 | } |
| #370 | } |
| #371 |