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
AbortSignalbefore each retry
Step 2: Parse Response
LLM response contains three parts:
| Field | Description |
|---|---|
content | Text reply (may contain <think> blocks) |
toolCalls | List of tool call requests |
finishReason | stop / tool_use / max_tokens / error |
When no tool calls:
- Strip
<think>...</think>blocks (DeepSeek reasoning format) - Send
text_deltaprogress event - Check if there are pending background sub-Agent results
- If background results exist and max drain rounds (3) not exceeded → inject results and continue loop
- 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:
- Abort check — if
signal.abortedis true, return immediately - Native search skip — if
nativeSearchEnabledand tool is native search, skip local execution - Channel role check — block all tools when
channelUserPermissions.canUseTool === false - PreToolUse Hook — execute registered hooks, block if
behavior === 'deny' - ExecutionFirewall — check if file paths and commands touch deny zones
- GuardianAgent — LLM security assessment (if enabled)
- Permission Callback — sensitive tools require user confirmation
- Execution — tool execution with timeout (
Promise.racevs timeout Promise) - PostToolUse Hook — async trigger, non-blocking (fire-and-forget)
Step 4: Result Handling
- Output truncation — results over
TOOL_RESULT_MAX_CHARS(50KB) are truncated - YieldSignal check — if tool throws
YieldSignal, immediately end loop - Iteration complete callback —
await onIterationComplete()saves messages to DB - Append to messages — tool results appended as
role: 'tool'messages - 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 condition | finishReason |
|---|---|
| LLM returns plain text (no tool calls) | stop |
| Max iterations reached | max_iterations |
| AbortSignal triggered | interrupted |
| LLM returns error | error |
| YieldSignal triggered | stop |
Constants Definition
| Constant | Value | Description |
|---|---|---|
MAX_ITERATIONS | 40 | Max loop iteration count |
TEMPERATURE | 0.1 | LLM temperature parameter |
MAX_TOKENS | 8192 | LLM max output tokens |
TOOL_RESULT_MAX_CHARS | 50,000 | Max tool result characters |
MAX_LLM_RETRIES | 2 | LLM call retry count |
LLM_RETRY_DELAY_MS | 1,000 | Retry base delay (ms) |
DEFAULT_TOOL_EXECUTION_TIMEOUT_MS | 300,000 | Single tool execution timeout (5 min) |
PERMISSION_TIMEOUT | 300,000 | Permission confirmation timeout (5 min) |
MAX_BACKGROUND_DRAIN_ROUNDS | 3 | Max consecutive background drain rounds |
DEFAULT_MAX_HISTORY | 100 | Max history messages loaded |
VERSION | 0.1.0 | TinyElf 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:
- Build
MessageBlock[](thinking + tool_use blocks) - Save assistant message (with tool call info)
- Save each tool result message (with
tool_resultblock) - Send
assistantMessageevent 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_recordstable viaTinyElfSdkAdapter.convertSingleMessage()converting to SDK JSONL style to share schema with Claude SDK for cross-engine interoperability. But the entiresdk_recordsmechanism (SDK-side IPC mirror + TinyElf-side synthesis) sunset on 2026-05-19 — Claude SDK-side IPC schema incompatible with disk JSONL schema (seedocs/dev/66_sdk_records/01_schema_divergence_investigation.md), and SDK 0.3.x now provides officialSessionStoreinterface (seepackages/desktop/app/main/services/agent-core/agent/SqliteSessionStore.ts). TinyElf now relies only onchat_messagesfor persistence, no SDK mirror needed.
The sdkDualWrite state field, initSdkDualWrite / initSdkDualWriteForResume / writeSdkRecords methods, and TinyElfSdkAdapter.ts file are all removed.
Key Files
| File | Path | Description |
|---|---|---|
| Agent Loop | tinyelf/TinyElfAgentLoop.ts | Core loop logic |
| Engine | tinyelf/TinyElfEngine.ts | IEngine implementation |
| Session Runner | tinyelf/TinyElfSessionRunner.ts | Session execution orchestration |
| LLM Adapter | tinyelf/TinyElfLLMAdapter.ts | LLM call adaptation |
| Prompt Builder | tinyelf/TinyElfPromptBuilder.ts | Message construction |
| Types | tinyelf/types.ts | Type and constant definitions |
All paths relative to packages/desktop/app/main/services/agent-core/engine/.
Extension Points
- Custom tool timeout —
TinyElfEngineConfig.toolExecutionTimeout - Custom iteration limit —
TinyElfEngineConfig.maxIterationsormaxTurns - Custom temperature —
TinyElfEngineConfig.temperature - Thinking level —
TinyElfEngineConfig.thinkingLevel(none/low/medium/high) - Hook extension — register PreToolUse / PostToolUse / SessionStart / SessionEnd hooks via
HookExecutor
Related Modules
| Module | Path | Relationship |
|---|---|---|
| ToolRegistryBuilder | tinyelf/TinyElfToolRegistryBuilder.ts | Build tool registry |
| SubagentManager | tinyelf/tools/SpawnTool.ts | Sub-Agent management |
| ExecutionFirewall | platform/security/ExecutionFirewall.ts | Layer 1 security |
| GuardianAgent | platform/security/GuardianAgent.ts | Layer 2 security |
| TinyElfPermissions | tinyelf/TinyElfPermissions.ts | Layer 3 security |