RFC-038: Agent Model Parameters (Temperature & Max Iterations)
RFC-038: Agent Model Parameters (Temperature & Max Iterations)
Document Information
- RFC ID: RFC-038
- Related PRD: FEAT-038 (Agent Model Parameters)
- Extends: RFC-002 (Agent Management), RFC-020 (Agent Enhancement)
- Depends On: RFC-002 (Agent Management), RFC-001 (Chat Interaction)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Draft
- Author: TBD
Overview
Background
Currently, OneClaw agents have no control over model sampling parameters or tool-use iteration limits:
- Temperature is not configurable – each provider uses its own default (typically ~1.0). All agents produce output with the same level of randomness regardless of their purpose.
- Max iterations is hardcoded to
MAX_TOOL_ROUNDS = 100inSendMessageUseCase. Every agent gets the same cap, even if a simple Q&A agent should stop much sooner.
Users need per-agent control over these parameters to match each agent’s behavior to its intended use case.
Goals
- Add
temperature: Float?andmaxIterations: Int?fields to the Agent domain model and Room entity - Pass temperature through the
ModelApiAdapter.sendMessageStream()interface to all three provider adapters - Use the agent’s max iterations as the tool-loop cap in
SendMessageUseCase - Add UI controls on the Agent Detail screen for both parameters
- Provide a Room migration from v7 to v8
Non-Goals
- Adding other sampling parameters (top_p, frequency_penalty, presence_penalty)
- Per-message temperature override
- Max output tokens / response length control
- Temperature presets UI (can be added later)
- Changing the
create_agenttool to accept these parameters
Technical Design
Architecture Overview
Changes span four layers: data model, data layer (Room + mapper), API adapter layer, feature layer (UI + ViewModel), and the chat use case.
┌─────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ AgentDetailScreen.kt │
│ ├── TemperatureField (new composable) │
│ └── MaxIterationsField (new composable) │
│ │
├─────────────────────────────────────────────────┤
│ ViewModel Layer │
│ │
│ AgentDetailViewModel.kt │
│ ├── updateTemperature(Float?) │
│ └── updateMaxIterations(Int?) │
│ │
├─────────────────────────────────────────────────┤
│ UseCase Layer │
│ │
│ SendMessageUseCase.kt │
│ ├── Use agent.maxIterations as loop cap │
│ └── Pass agent.temperature to adapter │
│ │
├─────────────────────────────────────────────────┤
│ API Adapter Layer │
│ │
│ ModelApiAdapter.sendMessageStream() │
│ ├── New parameter: temperature: Float? │
│ │ │
│ ├── OpenAiAdapter → "temperature": 0.7 │
│ ├── AnthropicAdapter → "temperature": 0.7 │
│ └── GeminiAdapter → "temperature": 0.7 │
│ │
├─────────────────────────────────────────────────┤
│ Data Layer │
│ │
│ Agent.kt (+ temperature, maxIterations) │
│ AgentEntity.kt (+ temperature, max_iterations)│
│ AgentMapper.kt (map new fields) │
│ AppDatabase.kt (v7 → v8 migration) │
└─────────────────────────────────────────────────┘
Core Components
1. Domain Model Changes
File: core/model/Agent.kt
data class Agent(
val id: String,
val name: String,
val description: String?,
val systemPrompt: String,
val preferredProviderId: String?,
val preferredModelId: String?,
val temperature: Float?, // NEW: 0.0-2.0, null = provider default
val maxIterations: Int?, // NEW: 1-100, null = global default (100)
val isBuiltIn: Boolean,
val createdAt: Long,
val updatedAt: Long
)
2. Room Entity Changes
File: data/local/entity/AgentEntity.kt
@Entity(tableName = "agents")
data class AgentEntity(
@PrimaryKey
val id: String,
val name: String,
val description: String?,
@ColumnInfo(name = "system_prompt")
val systemPrompt: String,
@ColumnInfo(name = "tool_ids")
val toolIds: String,
@ColumnInfo(name = "preferred_provider_id")
val preferredProviderId: String?,
@ColumnInfo(name = "preferred_model_id")
val preferredModelId: String?,
@ColumnInfo(name = "temperature") // NEW
val temperature: Float?,
@ColumnInfo(name = "max_iterations") // NEW
val maxIterations: Int?,
@ColumnInfo(name = "is_built_in")
val isBuiltIn: Boolean,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long
)
3. Room Migration (v7 -> v8)
File: data/local/db/AppDatabase.kt
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE agents ADD COLUMN temperature REAL DEFAULT NULL")
db.execSQL("ALTER TABLE agents ADD COLUMN max_iterations INTEGER DEFAULT NULL")
}
}
Register in database builder:
Room.databaseBuilder(context, AppDatabase::class.java, "oneclaw_shadow.db")
// ... existing migrations ...
.addMigrations(MIGRATION_7_8)
.build()
Update database version to 8 and update the seed callback to include the new columns (with NULL defaults).
4. Mapper Changes
File: data/local/mapper/AgentMapper.kt
fun AgentEntity.toDomain(): Agent = Agent(
id = id,
name = name,
description = description,
systemPrompt = systemPrompt,
preferredProviderId = preferredProviderId,
preferredModelId = preferredModelId,
temperature = temperature, // NEW
maxIterations = maxIterations, // NEW
isBuiltIn = isBuiltIn,
createdAt = createdAt,
updatedAt = updatedAt
)
fun Agent.toEntity(): AgentEntity = AgentEntity(
id = id,
name = name,
description = description,
systemPrompt = systemPrompt,
toolIds = "[]",
preferredProviderId = preferredProviderId,
preferredModelId = preferredModelId,
temperature = temperature, // NEW
maxIterations = maxIterations, // NEW
isBuiltIn = isBuiltIn,
createdAt = createdAt,
updatedAt = updatedAt
)
5. ModelApiAdapter Interface Change
File: data/remote/adapter/ModelApiAdapter.kt
Add an optional temperature parameter to sendMessageStream():
interface ModelApiAdapter {
fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
temperature: Float? = null // NEW
): Flow<StreamEvent>
// ... other methods unchanged ...
}
6. OpenAI Adapter Changes
File: data/remote/adapter/OpenAiAdapter.kt
Update sendMessageStream() signature to accept temperature and pass it to buildOpenAiRequest():
override fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
temperature: Float? // NEW
): Flow<StreamEvent> = channelFlow {
// ... existing code ...
val requestBody = buildOpenAiRequest(modelId, apiMessages, formattedTools, temperature)
// ...
}
private fun buildOpenAiRequest(
modelId: String,
messages: List<JsonObject>,
tools: List<JsonObject>?,
temperature: Float? // NEW
): String {
return buildJsonObject {
put("model", modelId)
put("stream", true)
// ... existing fields ...
if (temperature != null) {
put("temperature", temperature.toDouble())
}
}.toString()
}
7. Anthropic Adapter Changes
File: data/remote/adapter/AnthropicAdapter.kt
Same pattern – add temperature parameter and include in the request JSON:
override fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
temperature: Float? // NEW
): Flow<StreamEvent> = channelFlow {
// ...
val requestBody = buildAnthropicRequest(modelId, apiMessages, formattedTools, systemPrompt, temperature)
// ...
}
private fun buildAnthropicRequest(
modelId: String,
messages: List<JsonObject>,
tools: List<JsonObject>?,
systemPrompt: String?,
temperature: Float? // NEW
): String {
return buildJsonObject {
put("model", modelId)
put("max_tokens", 16000)
put("stream", true)
// ... existing fields ...
if (temperature != null) {
put("temperature", temperature.toDouble())
}
}.toString()
}
Note: Anthropic’s extended thinking mode has constraints on temperature (must be 1.0 when thinking is enabled). If the adapter detects thinking is enabled and temperature is set to a non-1.0 value, it should omit the temperature or log a warning. For V1, since thinking is always enabled in the current implementation, the adapter should only apply temperature when thinking is disabled, or document this limitation.
8. Gemini Adapter Changes
File: data/remote/adapter/GeminiAdapter.kt
Same pattern:
override fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
temperature: Float? // NEW
): Flow<StreamEvent> = channelFlow {
// ...
val requestBody = buildGeminiRequest(modelId, apiMessages, formattedTools, systemPrompt, temperature)
// ...
}
private fun buildGeminiRequest(
modelId: String,
messages: List<JsonObject>,
tools: List<JsonObject>?,
systemPrompt: String?,
temperature: Float? // NEW
): String {
return buildJsonObject {
// ... existing fields ...
putJsonObject("generationConfig") {
put("maxOutputTokens", 8192)
if (temperature != null) {
put("temperature", temperature.toDouble())
}
}
}.toString()
}
9. SendMessageUseCase Changes
File: feature/chat/usecase/SendMessageUseCase.kt
Two changes:
- Use
agent.maxIterationsas the loop cap - Pass
agent.temperatureto the adapter
fun execute(
sessionId: String,
userText: String,
agentId: String,
pendingMessages: Channel<String> = Channel(Channel.UNLIMITED)
): Flow<ChatEvent> = channelFlow {
// 1. Resolve agent
val agent = agentRepository.getAgentById(agentId) ?: run { ... }
// Determine effective max iterations
val effectiveMaxRounds = agent.maxIterations ?: MAX_TOOL_ROUNDS
// ... existing setup code ...
while (round < effectiveMaxRounds) { // CHANGED: was MAX_TOOL_ROUNDS
// ...
adapter.sendMessageStream(
apiBaseUrl = provider.apiBaseUrl,
apiKey = apiKey,
modelId = model.id,
messages = apiMessages,
tools = agentToolDefs,
systemPrompt = effectiveSystemPrompt,
temperature = agent.temperature // NEW
).collect { event -> ... }
// ...
round++
if (round < effectiveMaxRounds) { // CHANGED: was MAX_TOOL_ROUNDS
send(ChatEvent.ToolRoundStarting(round))
}
}
if (round >= effectiveMaxRounds) { // CHANGED: was MAX_TOOL_ROUNDS
send(ChatEvent.Error(
"Reached maximum tool call rounds ($effectiveMaxRounds). Stopping.",
ErrorCode.TOOL_ERROR, false
))
}
}
10. AgentDetailUiState Changes
File: feature/agent/AgentUiState.kt
data class AgentDetailUiState(
val agentId: String? = null,
val isBuiltIn: Boolean = false,
val isNewAgent: Boolean = false,
val name: String = "",
val description: String = "",
val systemPrompt: String = "",
val preferredProviderId: String? = null,
val preferredModelId: String? = null,
val temperature: Float? = null, // NEW
val maxIterations: Int? = null, // NEW
// Snapshot of persisted values used to derive hasUnsavedChanges
val savedName: String = "",
val savedDescription: String = "",
val savedSystemPrompt: String = "",
val savedPreferredProviderId: String? = null,
val savedPreferredModelId: String? = null,
val savedTemperature: Float? = null, // NEW
val savedMaxIterations: Int? = null, // NEW
val availableModels: List<ModelOptionItem> = emptyList(),
val generatePrompt: String = "",
val isGenerating: Boolean = false,
// Validation errors
val temperatureError: String? = null, // NEW
val maxIterationsError: String? = null, // NEW
val isLoading: Boolean = true,
val isSaving: Boolean = false,
val errorMessage: String? = null,
val successMessage: String? = null,
val showDeleteDialog: Boolean = false,
val navigateBack: Boolean = false
) {
val hasUnsavedChanges: Boolean
get() = if (isNewAgent) {
name.isNotBlank()
} else {
name != savedName ||
description != savedDescription ||
systemPrompt != savedSystemPrompt ||
preferredProviderId != savedPreferredProviderId ||
preferredModelId != savedPreferredModelId ||
temperature != savedTemperature || // NEW
maxIterations != savedMaxIterations // NEW
}
}
11. AgentDetailViewModel Changes
File: feature/agent/AgentDetailViewModel.kt
Add update and validation methods:
fun updateTemperature(value: Float?) {
val error = if (value != null && (value < 0f || value > 2f)) {
"Temperature must be between 0.0 and 2.0"
} else null
_uiState.update { it.copy(temperature = value, temperatureError = error) }
}
fun updateMaxIterations(value: Int?) {
val error = if (value != null && (value < 1 || value > 100)) {
"Max iterations must be between 1 and 100"
} else null
_uiState.update { it.copy(maxIterations = value, maxIterationsError = error) }
}
Update loadAgent() to populate the new fields:
private fun loadAgent(agentId: String) {
viewModelScope.launch {
val agent = agentRepository.getAgentById(agentId)
if (agent != null) {
_uiState.update {
it.copy(
// ... existing fields ...
temperature = agent.temperature,
maxIterations = agent.maxIterations,
savedTemperature = agent.temperature,
savedMaxIterations = agent.maxIterations,
// ...
)
}
}
}
}
Update saveAgent() to include the new fields and block save on validation errors:
fun saveAgent() {
val state = _uiState.value
if (state.temperatureError != null || state.maxIterationsError != null) return
val agent = Agent(
// ... existing fields ...
temperature = state.temperature,
maxIterations = state.maxIterations,
// ...
)
// ... existing save logic ...
}
12. AgentDetailScreen UI Changes
File: feature/agent/AgentDetailScreen.kt
Add two new composables after the Preferred Model dropdown:
// Temperature field
@Composable
private fun TemperatureField(
temperature: Float?,
error: String?,
onUpdate: (Float?) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(
text = "TEMPERATURE (optional)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
if (temperature != null) {
// Slider + value display
Row(verticalAlignment = Alignment.CenterVertically) {
Slider(
value = temperature,
onValueChange = { onUpdate(it) },
valueRange = 0f..2f,
steps = 19, // 0.0, 0.1, 0.2, ..., 2.0
enabled = enabled,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f".format(temperature),
style = MaterialTheme.typography.bodyMedium
)
}
TextButton(
onClick = { onUpdate(null) },
modifier = Modifier.align(Alignment.End)
) {
Text("Clear")
}
} else {
OutlinedButton(
onClick = { onUpdate(1.0f) }, // Set to 1.0 as starting value
enabled = enabled,
modifier = Modifier.fillMaxWidth()
) {
Text("Set temperature (provider default when not set)")
}
}
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
}
// Max iterations field
@Composable
private fun MaxIterationsField(
maxIterations: Int?,
error: String?,
onUpdate: (Int?) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(
text = "MAX ITERATIONS (optional)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
if (maxIterations != null) {
OutlinedTextField(
value = maxIterations.toString(),
onValueChange = { text ->
val parsed = text.filter { it.isDigit() }.toIntOrNull()
onUpdate(parsed)
},
label = { Text("Max iterations (1-100)") },
enabled = enabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = error != null,
supportingText = error?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth()
)
TextButton(
onClick = { onUpdate(null) },
modifier = Modifier.align(Alignment.End)
) {
Text("Clear")
}
} else {
OutlinedButton(
onClick = { onUpdate(10) }, // Set to 10 as a reasonable starting value
enabled = enabled,
modifier = Modifier.fillMaxWidth()
) {
Text("Set max iterations (default: 100 when not set)")
}
}
}
}
Integrate into the LazyColumn in AgentDetailScreen:
// After PreferredModelDropdown item
item {
TemperatureField(
temperature = uiState.temperature,
error = uiState.temperatureError,
onUpdate = { viewModel.updateTemperature(it) },
enabled = !uiState.isBuiltIn,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
MaxIterationsField(
maxIterations = uiState.maxIterations,
error = uiState.maxIterationsError,
onUpdate = { viewModel.updateMaxIterations(it) },
enabled = !uiState.isBuiltIn,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
)
}
Data Model
Changes Summary
| Component | Field | Type | Default | Column Name |
|---|---|---|---|---|
Agent |
temperature |
Float? |
null |
- |
Agent |
maxIterations |
Int? |
null |
- |
AgentEntity |
temperature |
Float? |
null |
temperature |
AgentEntity |
maxIterations |
Int? |
null |
max_iterations |
Room column types: REAL for temperature, INTEGER for max_iterations.
API Design
Modified Interface
// ModelApiAdapter.kt -- only sendMessageStream changes
fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?,
temperature: Float? = null // NEW -- default null preserves backward compat
): Flow<StreamEvent>
The default value null ensures all existing callers (e.g., generateSimpleCompletion internal usage) continue to work without modification.
UI Layer Design
AgentDetailScreen Layout (Updated)
┌──────────────────────────────────┐
│ <- Edit Agent [Save]│
├──────────────────────────────────┤
│ GENERATE FROM PROMPT │ (create mode only)
│ ┌──────────────────────────────┐ │
│ │ Describe the agent... │ │
│ └──────────────────────────────┘ │
│ [Generate] │
├──────────────────────────────────┤
│ ┌──────────────────────────────┐ │
│ │ Name │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Description (optional) │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ System Prompt * │ │
│ └──────────────────────────────┘ │
├──────────────────────────────────┤
│ PREFERRED MODEL (optional) │
│ ┌──────────────────────────────┐ │
│ │ Using global default v │ │
│ └──────────────────────────────┘ │
├──────────────────────────────────┤
│ TEMPERATURE (optional) │
│ [0.0 ========|======== 2.0] 0.7 │
│ Provider default when not set │
│ [Clear] │
├──────────────────────────────────┤
│ MAX ITERATIONS (optional) │
│ ┌──────────────────────────────┐ │
│ │ 10 │ │
│ └──────────────────────────────┘ │
│ Default: 100 when not set │
│ [Clear] │
├──────────────────────────────────┤
│ [ Clone Agent ] │
│ [ Delete Agent ] │
└──────────────────────────────────┘
Dependency Injection
No new Koin module registrations needed. Existing ViewModel and UseCase registrations already receive all required dependencies. The only change is that SendMessageUseCase now reads two additional fields from the Agent object it already retrieves.
Implementation Steps
Phase 1: Data Layer (model + entity + migration + mapper)
- Add
temperature: Float?andmaxIterations: Int?toAgent.kt - Add
temperature: Float?andmaxIterations: Int?columns toAgentEntity.kt - Create
MIGRATION_7_8inAppDatabase.kt(ALTER TABLE ADD COLUMN x2) - Update database version to 8
- Register migration in database builder
- Update seed callback INSERT to include the new columns (NULL defaults)
- Update
AgentMapper.ktto map both new fields - Add unit tests for mapper with null and non-null values
Phase 2: API Adapter Layer (temperature passthrough)
- Add
temperature: Float? = nullparameter toModelApiAdapter.sendMessageStream() - Update
OpenAiAdapter.sendMessageStream()andbuildOpenAiRequest()to accept and include temperature - Update
AnthropicAdapter.sendMessageStream()andbuildAnthropicRequest()to accept and include temperature - Update
GeminiAdapter.sendMessageStream()andbuildGeminiRequest()to accept and include temperature - Add unit tests verifying temperature is included in request JSON when non-null
- Add unit tests verifying temperature is omitted when null
Phase 3: Chat Pipeline (SendMessageUseCase)
- Change loop condition from
MAX_TOOL_ROUNDStoagent.maxIterations ?: MAX_TOOL_ROUNDS - Pass
agent.temperatureas the temperature parameter toadapter.sendMessageStream() - Update the max-rounds-reached error message to use the effective limit
- Add unit tests for custom max iterations behavior
- Add unit tests verifying temperature is forwarded to adapter
Phase 4: UI Layer (AgentDetailScreen + ViewModel)
- Add
temperature,maxIterations,savedTemperature,savedMaxIterations,temperatureError,maxIterationsErrortoAgentDetailUiState - Update
hasUnsavedChangesto include temperature and maxIterations comparison - Add
updateTemperature(Float?)andupdateMaxIterations(Int?)toAgentDetailViewModel - Update
loadAgent()to populate new fields from agent - Update
saveAgent()to include new fields and block on validation errors - Create
TemperatureFieldcomposable - Create
MaxIterationsFieldcomposable - Integrate both composables into
AgentDetailScreenLazyColumn - Add ViewModel unit tests for update + validation
- Add ViewModel unit tests for save with new fields
Testing Strategy
Unit Tests
Data Layer:
AgentMapper: verifytoDomain()andtoEntity()map temperature and maxIterations correctly (null and non-null)- Room migration test: verify MIGRATION_7_8 adds both columns without data loss
API Adapters:
OpenAiAdapter: verifybuildOpenAiRequest()includes"temperature": 0.7when set, omits when nullAnthropicAdapter: verifybuildAnthropicRequest()includes temperature when set, omits when nullGeminiAdapter: verifybuildGeminiRequest()includes temperature ingenerationConfigwhen set, omits when null
SendMessageUseCase:
- Verify loop stops after
agent.maxIterationsrounds when set - Verify loop uses
MAX_TOOL_ROUNDSwhenagent.maxIterationsis null - Verify
agent.temperatureis passed to adapter call - Verify null temperature is passed to adapter when not set
AgentDetailViewModel:
updateTemperature(0.7f): state updates, no errorupdateTemperature(2.5f): state updates, error setupdateTemperature(null): state clears to null, no errorupdateMaxIterations(10): state updates, no errorupdateMaxIterations(0): state updates, error setupdateMaxIterations(101): state updates, error setupdateMaxIterations(null): state clears to null, no errorsaveAgent()with validation errors: does not savehasUnsavedChangesdetects temperature changehasUnsavedChangesdetects maxIterations change
Integration Tests
- Create agent with temperature = 0.5 and maxIterations = 10, reload, verify values persist
- Update agent to clear temperature (set to null), reload, verify null
Manual Tests
- Open Agent Detail for built-in General Assistant – both fields should show “not set” / default state
- Create new agent, set temperature to 0.3 and max iterations to 5, save, re-open, verify values
- Edit custom agent, change temperature via slider, verify value updates in real time
- Edit custom agent, set max iterations to 0 – should show validation error, save blocked
- Clone an agent with temperature 0.8 – cloned agent should have temperature 0.8
- Start chat with agent that has temperature 0.2 – verify API request includes temperature (check logcat)
- Start chat with agent that has maxIterations 3 – verify tool loop stops after 3 rounds
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-03-01 | 0.1 | Initial version | - |