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