Skip to main content

TinyElf Agent Loop Deep Dive

TinyElf is Elftia's built-in Agent engine, implementing the classic "call LLM → execute tool → observe result → loop" pattern. This article provides detailed analysis of the complete Agent Loop algorithm.

Overall Architecture

graph TB
Engine["TinyElfEngine"] --> SessionRunner["TinyElfSessionRunner<br/>runSession()"]

SessionRunner --> ToolBuilder["TinyElfToolRegistryBuilder<br/>buildToolRegistry()"]
SessionRunner --> PromptBuilder["TinyElfPromptBuilder<br/>buildMessages()"]
SessionRunner --> LoopInst["TinyElfAgentLoop<br/>instantiation"]
SessionRunner --> |"run"| Loop["AgentLoop.run()"]

ToolBuilder --> FileTools["FileSystemTools"]
ToolBuilder --> ShellT["ShellTool"]
ToolBuilder --> WebT["WebSearch/WebFetch"]
ToolBuilder --> SkillsT["Skills tools"]
ToolBuilder --> SpawnT["SpawnTool"]
ToolBuilder --> McpT["MCP tools"]
ToolBuilder --> SessionT["Session tools"]

Loop --> LLMCall["callLLMWithRetry()"]
Loop --> ToolExec["executeToolCalls()"]
Loop --> Save["onIterationComplete()"]

Session Execution Flow

sequenceDiagram
participant R as AgentRouter
participant E as TinyElfEngine
participant B as ToolRegistryBuilder
participant S as SessionRunner
participant L as AgentLoop
participant LLM as TinyElfLLMAdapter
participant T as ToolRegistry

R->>E: startSession(ctx)
E->>E: saveMessage(user)
E->>E: sender.send(userMessage)
E->>B: buildToolRegistry()
B-->>E: { toolRegistry, subagentManager, ... }
E->>S: runSession(deps, params)
S->>S: new TinyElfLLMAdapter()
S->>S: new ExecutionFirewall()
S->>S: buildGuardian()
S->>L: new TinyElfAgentLoop()
S->>S: buildMessages()
S->>L: run(messages, callbacks)

loop Up to 40 iterations
L->>LLM: callLLMWithRetry(messages, tools)
LLM-->>L: { content, toolCalls, usage }

alt No tool calls
L->>L: Check background results
alt Has background results
L->>L: Inject background results into messages
L->>L: continue (not consuming iteration)
else No background results
L-->>S: AgentLoopResult (finishReason: stop)
end
else Has tool calls
L->>L: executeToolCalls(parallel)
Note over L,T: Security pipeline: Firewall → Guardian → Permission
L->>T: execute(tool)
T-->>L: ToolCallResult
L->>S: onIterationComplete(data)
S->>S: saveMessage(assistant + tools)
S->>S: sender.send(assistantMessage)
L->>L: Append tool results to messages
L->>L: Drain background results
end
end

S->>S: saveFinalResult()
S->>S: sender.send(result + complete)

Agent Loop Algorithm

Step 1: Call LLM (with retry)

callLLMWithRetry(messages, tools, signal)
  • Max MAX_LLM_RETRIES (2) attempts
  • Retry delay: LLM_RETRY_DELAY_MS * (attempt + 1) (increments 1s, 2s, 3s)
  • Retry condition: LLM returns finishReason === 'error' with no content, or throws exception
  • Return error message after all attempts fail
  • Check AbortSignal before each retry

Step 2: Parse Response

LLM response contains three parts:

FieldDescription
contentText reply (may contain <think> blocks)
toolCallsList of tool call requests
finishReasonstop / tool_use / max_tokens / error

When no tool calls:

  1. Strip <think>...</think> blocks (DeepSeek reasoning format)
  2. Send text_delta progress event
  3. Check if there are pending background sub-Agent results
  4. If background results exist and max drain rounds (3) not exceeded → inject results and continue loop
  5. Otherwise return final result

Step 3: Tool Execution (parallel)

All tool calls in the same LLM response are executed in parallel (Promise.all). Each tool call goes through the following security pipeline:

graph LR
TC["Tool call request"] --> Abort{"Check Abort"}
Abort -->|Aborted| Err1["Return error"]
Abort -->|Not aborted| NS{"Native search skip?"}
NS -->|Yes| Skip["Skip (provider handles)"]
NS -->|No| Chan{"Channel role check"}
Chan -->|Denied| Err2["No permission"]
Chan -->|Allowed| Hook{"PreToolUse Hook"}
Hook -->|deny| Err3["Hook denied"]
Hook -->|allow| FW{"ExecutionFirewall"}
FW -->|blocked| Err4["Firewall denied"]
FW -->|allowed| GA{"GuardianAgent"}
GA -->|blocked| Err5["Guardian denied"]
GA -->|allowed| Perm{"Permission Callback"}
Perm -->|deny| Err6["User denied"]
Perm -->|allow| Exec["Execute tool<br/>5 minute timeout"]
Exec --> PostHook["PostToolUse Hook<br/>(fire-and-forget)"]

Security pipeline detailed steps:

  1. Abort check — if signal.aborted is true, return immediately
  2. Native search skip — if nativeSearchEnabled and tool is native search, skip local execution
  3. Channel role check — block all tools when channelUserPermissions.canUseTool === false
  4. PreToolUse Hook — execute registered hooks, block if behavior === 'deny'
  5. ExecutionFirewall — check if file paths and commands touch deny zones
  6. GuardianAgent — LLM security assessment (if enabled)
  7. Permission Callback — sensitive tools require user confirmation
  8. Execution — tool execution with timeout (Promise.race vs timeout Promise)
  9. PostToolUse Hook — async trigger, non-blocking (fire-and-forget)

Step 4: Result Handling

  1. Output truncation — results over TOOL_RESULT_MAX_CHARS (50KB) are truncated
  2. YieldSignal check — if tool throws YieldSignal, immediately end loop
  3. Iteration complete callbackawait onIterationComplete() saves messages to DB
  4. Append to messages — tool results appended as role: 'tool' messages
  5. Background result drain — inject completed background sub-Agent results into message list

Step 5: Loop Control

Back to step 1 until one of these exit conditions is met:

Exit conditionfinishReason
LLM returns plain text (no tool calls)stop
Max iterations reachedmax_iterations
AbortSignal triggeredinterrupted
LLM returns errorerror
YieldSignal triggeredstop

Constants Definition

ConstantValueDescription
MAX_ITERATIONS40Max loop iteration count
TEMPERATURE0.1LLM temperature parameter
MAX_TOKENS8192LLM max output tokens
TOOL_RESULT_MAX_CHARS50,000Max tool result characters
MAX_LLM_RETRIES2LLM call retry count
LLM_RETRY_DELAY_MS1,000Retry base delay (ms)
DEFAULT_TOOL_EXECUTION_TIMEOUT_MS300,000Single tool execution timeout (5 min)
PERMISSION_TIMEOUT300,000Permission confirmation timeout (5 min)
MAX_BACKGROUND_DRAIN_ROUNDS3Max consecutive background drain rounds
DEFAULT_MAX_HISTORY100Max history messages loaded
VERSION0.1.0TinyElf version

Background Sub-Agent Integration

TinyElf supports parallel execution of background sub-Agents. The main loop cooperates with background tasks through the following mechanism:

Result Injection

pushBackgroundResult(result: BackgroundAgentResult): void

After background sub-Agent completes, push results into main loop queue via SubagentManager.setBackgroundCompleteCallback.

Drain Strategy

  • Check background result queue at end of each iteration
  • When new results exist, inject with [Background agent "label" (runId) outcome] format
  • If LLM returns plain text but has pending background results, continue loop (max 3 rounds)
  • Actual tool calls reset drain counter

Message Persistence

At each iteration completion, save messages via onIterationComplete callback:

  1. Build MessageBlock[] (thinking + tool_use blocks)
  2. Save assistant message (with tool call info)
  3. Save each tool result message (with tool_result block)
  4. Send assistantMessage event via IPC

Final plain text reply saved separately via saveFinalResult(), containing execution stats (inputTokens, outputTokens).

SDK Dual Write (removed, 2026-05-19)

Historical note: TinyElf originally wrote each message to the sdk_records table via TinyElfSdkAdapter.convertSingleMessage() converting to SDK JSONL style to share schema with Claude SDK for cross-engine interoperability. But the entire sdk_records mechanism (SDK-side IPC mirror + TinyElf-side synthesis) sunset on 2026-05-19 — Claude SDK-side IPC schema incompatible with disk JSONL schema (see docs/dev/66_sdk_records/01_schema_divergence_investigation.md), and SDK 0.3.x now provides official SessionStore interface (see packages/desktop/app/main/services/agent-core/agent/SqliteSessionStore.ts). TinyElf now relies only on chat_messages for persistence, no SDK mirror needed.

The sdkDualWrite state field, initSdkDualWrite / initSdkDualWriteForResume / writeSdkRecords methods, and TinyElfSdkAdapter.ts file are all removed.

Key Files

FilePathDescription
Agent Looptinyelf/TinyElfAgentLoop.tsCore loop logic
Enginetinyelf/TinyElfEngine.tsIEngine implementation
Session Runnertinyelf/TinyElfSessionRunner.tsSession execution orchestration
LLM Adaptertinyelf/TinyElfLLMAdapter.tsLLM call adaptation
Prompt Buildertinyelf/TinyElfPromptBuilder.tsMessage construction
Typestinyelf/types.tsType and constant definitions

All paths relative to packages/desktop/app/main/services/agent-core/engine/.

Extension Points

  • Custom tool timeoutTinyElfEngineConfig.toolExecutionTimeout
  • Custom iteration limitTinyElfEngineConfig.maxIterations or maxTurns
  • Custom temperatureTinyElfEngineConfig.temperature
  • Thinking levelTinyElfEngineConfig.thinkingLevel (none/low/medium/high)
  • Hook extension — register PreToolUse / PostToolUse / SessionStart / SessionEnd hooks via HookExecutor
ModulePathRelationship
ToolRegistryBuildertinyelf/TinyElfToolRegistryBuilder.tsBuild tool registry
SubagentManagertinyelf/tools/SpawnTool.tsSub-Agent management
ExecutionFirewallplatform/security/ExecutionFirewall.tsLayer 1 security
GuardianAgentplatform/security/GuardianAgent.tsLayer 2 security
TinyElfPermissionstinyelf/TinyElfPermissions.tsLayer 3 security