Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .trailblaze-sync
Original file line number Diff line number Diff line change
@@ -1 +1 @@
8141286d5a206c6ee3b16169f1b7224ac2d881aa
9091a0cea8a1703fc9ed45ec128a4d4a00bf5206
25 changes: 17 additions & 8 deletions docs/adding_a_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,14 @@ Set the provider's API key (`GOOGLE_API_KEY` here — see the

## How your entry combines with the built-ins

Workspace config **adds to** the built-in registry — it does not replace it. Entries are
matched by `id`:
Declaring an `llm.providers` block **replaces** the built-in catalog: the models Trailblaze
offers become exactly the ones you list, under exactly the providers you declare. With no
`llm.providers` block at all you get the built-in catalog unchanged. So if you want your
model *alongside* the built-ins rather than instead of them, list the built-in models you
still want next to it.

The built-in registry still does work for you on each entry — ids are matched against it, so
you only spell out what differs:

| Your `id` | Result |
|---|---|
Expand Down Expand Up @@ -109,10 +115,10 @@ llm:
model: "gemma4:31b"
```

Quote Ollama ids — the `:` makes them look like YAML mappings otherwise. Trailblaze also
discovers whatever `ollama list` reports at runtime, so a model already pulled locally shows
up without any config; listing it explicitly is how you tell teammates which model the
project expects. Nothing is auto-downloaded — they run `ollama pull gemma4:31b`.
Quote Ollama ids — the `:` makes them look like YAML mappings otherwise. Trailblaze offers
the Ollama models in the built-in catalog plus whatever you list here; pulling a model
locally does not make it selectable on its own, so an id outside the built-in catalog has to
be declared. Nothing is auto-downloaded either — teammates run `ollama pull gemma4:31b`.

### A model behind your own gateway

Expand Down Expand Up @@ -161,8 +167,11 @@ The workspace file wins over the user file; environment variables win over both.
precedence table: [Configuration → Precedence](configuration.md#precedence).

Committing the workspace file is the recommended shape for teams — everyone who clones the
repo gets a working model with no per-machine setup, and the project is pinned to models you
have actually validated rather than to whatever the current release happens to ship.
repo sees the same set of models, and the project is pinned to models you have actually
validated rather than to whatever the current release happens to ship. Each person still
picks their active model once with `trailblaze config llm` (that selection is persisted per
machine); `llm.defaults.model` is read by the Android on-device runner, not by the desktop
app or CLI.

## Verifying

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ llm:
| Key | Type | What it does |
|---|---|---|
| `defaults` | map | Workspace-wide defaults — see below |
| `targets` | list of ids | Target-trailmap ids this workspace opts into. **Omit to auto-discover** every target trailmap under `<workspace>/trailmaps/`. Listing ids is how a workspace with many trailmaps loads only a subset. Each id must be a *target* trailmap (one with a `target:` block); library trailmaps reach scope through a target's `dependencies:`. |
| `targets` | list of ids | Target-trailmap ids this workspace opts into. **Omit to auto-discover** every target trailmap under `<workspace>/trails/config/trailmaps/`. Listing ids is how a workspace with many trailmaps loads only a subset. Each id must be a *target* trailmap (one with a `target:` block); library trailmaps reach scope through a target's `dependencies:`. |
| `toolsets` | list | Extra toolsets, either written inline or pulled in with `ref: path/to/toolset.yaml` |
| `tools` | list | Extra tools, same inline-or-`ref:` shape |
| `providers` | list | Reserved for standalone LLM provider files. Provider and model definitions are read from the `llm:` block today — put them there. |
Expand Down
1 change: 0 additions & 1 deletion docs/generated/LLM_MODELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ Trailblaze ships with the following built-in models. When you reference a model
| `gemini-3.5-flash` | 1M | 65K | $1.50 | $9.00 | $0.15 | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemini-3.5-flash-lite` | 1M | 65K | $0.30 | $2.50 | $0.03 | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemini-3.6-flash` | 1M | 65K | $1.50 | $7.50 | $0.15 | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemma-4-12b-it` | 262K | 32K | free | free | free | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemma-4-26b-a4b-it` | 262K | 32K | free | free | free | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |
| `gemma-4-31b-it` | 262K | 32K | free | free | free | basic-json-schema, completion, document, image, multipleChoices, openai-endpoint-chat-completions, openai-endpoint-responses, speculation, standard-json-schema, temperature, toolChoice, tools |

Expand Down
63 changes: 62 additions & 1 deletion docs/llm_configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,14 @@ llm:
models:
- id: qwen3-vl:8b
- id: qwen3.5:27b
- id: llama3.2:latest
defaults:
model: qwen3-vl:8b
```

Both ids above are in the built-in registry, so they need no spec. A model the registry
doesn't know falls back to a generic context length — give those an explicit spec, as
[Custom model specs](#custom-model-specs) below shows.

Models that are not installed will show a warning in the desktop app UI indicating the model is not available via Ollama. Developers can install them with:

```bash
Expand All @@ -254,6 +257,64 @@ For Ollama models not in the built-in registry, provide context length and outpu
max_output_tokens: 8192
```

For a text-only model, also set `vision: false` — otherwise Trailblaze attaches screenshots
to requests (including AI-backed assertions) and Ollama rejects them with a 400.

### Context window (`num_ctx`)

Trailblaze requests a 64K context window (`num_ctx: 65536`) on every Ollama call, clamped
down to the model's declared `context_length` when that is lower.

**Trailblaze now owns this setting.** A `num_ctx` on the request sits at the top of
Ollama's precedence chain — above a `PARAMETER num_ctx` in the model's Modelfile, and above
the server's `OLLAMA_CONTEXT_LENGTH` — so neither of those takes effect for Trailblaze's
requests any more. Use `TRAILBLAZE_OLLAMA_NUM_CTX` (below) rather than a server-side
setting, which will look like it is being ignored.

The clamp only applies to a model whose `context_length` Trailblaze knows: one in the
built-in registry, or one you declared with an explicit spec. A model with neither falls
back to a generic context length, and the clamp does nothing for it — so if such a model's
real window is smaller than the requested value, declare its `context_length` (see
[Custom model specs](#custom-model-specs)) instead of relying on the clamp.

When a request doesn't ask for a context window, Ollama picks one itself, sized to the
memory it has available rather than to what the model supports — so the same model gets a
large window on a workstation and a very small one on a laptop. A single Trailblaze agent
turn (screenshot + view hierarchy + tool definitions) is ~20K tokens on a content-heavy
screen, which is more than the low end of that range, and the turn fails with
`exceed_context_size_error`. Asking explicitly is what makes the context predictable
across machines instead of a property of the developer's hardware.

64K is a deliberate middle: several multi-turn agent loops fit, and the KV cache still
fits a laptop. On a machine with a lot of memory Ollama's automatic choice can be larger
than 64K, and an explicit request replaces it — raise it with the override below if you
run very long loops on such a machine.

Override the requested value with `TRAILBLAZE_OLLAMA_NUM_CTX`:

```bash
TRAILBLAZE_OLLAMA_NUM_CTX=32768 trailblaze run --no-daemon login.trail.yaml
```

Lower it if your machine can't afford the 64K KV cache for a larger model; raise it for
very long agent loops. Malformed or non-positive values fall back to the default.

The value is read from the process that builds the Ollama client. `trailblaze run`
normally hands the run to a background daemon, which inherits the environment it was
started with — so a one-shot prefix like the above only applies with `--no-daemon`. To
change it for daemon-backed runs, restart the daemon with the variable set:

```bash
trailblaze stop
TRAILBLAZE_OLLAMA_NUM_CTX=32768 trailblaze app start
```

The override applies to host-side clients only. Android on-device runs always request the
64K default, because the instrumentation process has no host environment to read. If you
set the override *and* run on-device AI legs against the same Ollama server, the two ends
request different context lengths and Ollama reloads the model on every alternation — leave
it unset for those runs.

## Environment Variables

Standard environment variables for authentication:
Expand Down
2 changes: 1 addition & 1 deletion docs/llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import xyz.block.trailblaze.agent.TrailblazeRunner
import xyz.block.trailblaze.llm.TrailblazeLlmModel
import xyz.block.trailblaze.llm.providers.OpenAITrailblazeLlmModelList

private val trailblazeLlmModel: TrailblazeLlmModel = OpenAITrailblazeLlmModelList.OPENAI_GPT_4_1
private val trailblazeLlmModel: TrailblazeLlmModel = OpenAITrailblazeLlmModelList.OPENAI_DEFAULT
private val llmClient: LLMClient = OpenAILLMClient("API_KEY_HERE")
TrailblazeRunner(
llmClient = llmClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ai.koog.prompt.executor.clients.LLMClient
import ai.koog.prompt.executor.clients.anthropic.AnthropicClientSettings
import ai.koog.prompt.executor.clients.anthropic.AnthropicLLMClient
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.executor.ollama.client.ContextWindowStrategy
import ai.koog.prompt.executor.ollama.client.OllamaClient
import ai.koog.prompt.llm.LLMProvider
import org.junit.Test
Expand All @@ -20,6 +21,7 @@ import xyz.block.trailblaze.devices.TrailblazeDeviceClassifier
import xyz.block.trailblaze.llm.TrailblazeLlmProvider
import xyz.block.trailblaze.llm.config.BuiltInLlmModelRegistry
import xyz.block.trailblaze.llm.config.LlmAuthResolver
import xyz.block.trailblaze.llm.config.OllamaContextWindow
import xyz.block.trailblaze.http.DefaultDynamicLlmClient
import xyz.block.trailblaze.http.DynamicLlmClient
import xyz.block.trailblaze.http.NoOpLlmClient
Expand Down Expand Up @@ -87,6 +89,11 @@ class AndroidStandaloneServerTest : BaseAndroidStandaloneServerTest() {
LLMProvider.Ollama to OllamaClient(
baseUrl = ollamaBaseUrl ?: "http://localhost:11434",
httpClientFactory = httpClientFactory,
// Request num_ctx on every call — left to itself Ollama sizes the window to the
// memory it has available, which can reject real agent turns (~20K tokens)
// regardless of the model's declared context.
// No env override on-device; the host-side knob is TRAILBLAZE_OLLAMA_NUM_CTX.
contextWindowStrategy = ContextWindowStrategy.Companion.Fixed(OllamaContextWindow.DEFAULT_NUM_CTX),
),
)
InstrumentationArgUtil.getInstrumentationArg(LlmAuthResolver.resolve(TrailblazeLlmProvider.OPENAI))?.let { openAiApiKey ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ai.koog.prompt.executor.clients.anthropic.AnthropicLLMClient
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.executor.clients.openrouter.OpenRouterLLMClient
import ai.koog.prompt.executor.ollama.client.ContextWindowStrategy
import ai.koog.prompt.executor.ollama.client.OllamaClient
import ai.koog.prompt.llm.LLMProvider
import xyz.block.trailblaze.android.openai.OpenAiInstrumentationArgUtil
Expand All @@ -20,6 +21,7 @@ import com.charleskorn.kaml.YamlConfiguration
import xyz.block.trailblaze.llm.config.BuiltInLlmModelRegistry
import xyz.block.trailblaze.llm.config.TrailblazeConfigPaths
import xyz.block.trailblaze.llm.config.LlmAuthResolver
import xyz.block.trailblaze.llm.config.OllamaContextWindow
import xyz.block.trailblaze.llm.config.TrailblazeProjectYamlConfig
import xyz.block.trailblaze.util.Console

Expand Down Expand Up @@ -168,6 +170,11 @@ object AndroidLlmClientResolver {
OllamaClient(
baseUrl = ollamaBaseUrl ?: "http://localhost:11434",
httpClientFactory = httpClientFactory,
// Request num_ctx on every call — left to itself Ollama sizes the window to the
// memory it has available, which can reject real agent turns (~20K tokens)
// regardless of the model's declared context.
// No env override on-device; the host-side knob is TRAILBLAZE_OLLAMA_NUM_CTX.
contextWindowStrategy = ContextWindowStrategy.Companion.Fixed(OllamaContextWindow.DEFAULT_NUM_CTX),
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package xyz.block.trailblaze.android.accessibility

import maestro.KeyCode
import xyz.block.trailblaze.api.TrailblazeNodeSelector
import xyz.block.trailblaze.model.TapRouteOverride

/**
* Default timeout for actions that poll the accessibility tree waiting for an element to appear
Expand Down Expand Up @@ -127,6 +128,11 @@ sealed interface AccessibilityAction {
* out, and the trail fails on what's supposed to be a best-effort step.
*/
val optional: Boolean = false,
/**
* Pins the dispatch route for this tap instead of letting [planActionClickRoute] choose it
* from the resolved node's fields. Carried from the recording; null means the gate decides.
*/
val tapRoute: TapRouteOverride? = null,
) : AccessibilityAction {
override val description: String
get() = "${if (longPress) "Long press" else "Tap"} on ${nodeSelector.description()}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import xyz.block.trailblaze.api.TrailblazeNode
import xyz.block.trailblaze.api.TrailblazeNodeSelector
import xyz.block.trailblaze.api.TrailblazeNodeSelectorResolver
import xyz.block.trailblaze.devices.TrailblazeDeviceClassifier
import xyz.block.trailblaze.model.TapRouteOverride
import xyz.block.trailblaze.util.Console

/**
Expand Down Expand Up @@ -875,7 +876,9 @@ class AccessibilityDeviceManager(
unfilteredTree, result.node, center.first, center.second, action.nodeSelector.description(),
)
val route =
tapOrLongPressOnResolvedNode(result.node, center.first, center.second, action.longPress)
tapOrLongPressOnResolvedNode(
result.node, center.first, center.second, action.longPress, action.tapRoute,
)
return ExecutionResult(
resolvedX = center.first,
resolvedY = center.second,
Expand All @@ -894,7 +897,9 @@ class AccessibilityDeviceManager(
unfilteredTree, chosen, center.first, center.second, action.nodeSelector.description(),
)
val route =
tapOrLongPressOnResolvedNode(chosen, center.first, center.second, action.longPress)
tapOrLongPressOnResolvedNode(
chosen, center.first, center.second, action.longPress, action.tapRoute,
)
return ExecutionResult(
resolvedX = center.first,
resolvedY = center.second,
Expand Down Expand Up @@ -970,21 +975,27 @@ class AccessibilityDeviceManager(
centerX: Int,
centerY: Int,
longPress: Boolean,
tapRoute: TapRouteOverride?,
): TapDispatchRoute {
if (actionClickRouteDisabled()) {
Console.log("[tap-route] kill-switch set, using gesture at ($centerX,$centerY)")
tapOrLongPress(centerX, centerY, longPress)
return TapDispatchRoute.GESTURE
}
val plan = planActionClickRoute(resolvedNode, longPress)
val plan = planActionClickRoute(resolvedNode, longPress, tapRoute)
if (plan == null) {
// Surface the gate-relevant fields of the resolved node so an oncall debugging
// "why did this tap go via gesture?" can map each value to the matching condition
// in `planActionClickRoute`'s kdoc without re-running the session. The 7-condition
// gate makes a generic "declined" message uninformative.
val why = if (tapRoute == TapRouteOverride.GESTURE) {
"recording pinned gesture"
} else {
"gate declined ACTION_CLICK"
}
Console.log(
"[tap-route] gesture at ($centerX,$centerY) — gate declined ACTION_CLICK " +
"(${describeNodeForRouteLog(resolvedNode, longPress)})",
"[tap-route] gesture at ($centerX,$centerY) — $why " +
"(${describeNodeForRouteLog(resolvedNode, longPress, tapRoute)})",
)
tapOrLongPress(centerX, centerY, longPress)
return TapDispatchRoute.GESTURE
Expand Down Expand Up @@ -1194,23 +1205,37 @@ internal fun scrollToSwipeDirection(direction: AccessibilityAction.Direction): A
* still taps correctly, while a stateless container wrongly sent to `ACTION_CLICK` changes
* what the tap does. A misrouting node is diagnosable from the `[tap-route]` log and the
* whole route is revertible with `TRAILBLAZE_DISABLE_ACTION_CLICK_ROUTE`.
*
* [tapRoute] lets a single recorded step pin its own route for the case this gate cannot decide:
* two rows that need opposite routes presenting identical fields (see [TapRouteOverride]). The pin
* reaches only the leaf-vs-container judgement above. Every check before it is a precondition for
* `ACTION_CLICK` being dispatchable at all, so a step that pins `ACTION_CLICK` on a long-press, an
* editable field, an invisible node, or a node that doesn't advertise the action still routes to
* gesture rather than dispatching an action the node can't answer.
*/
internal fun planActionClickRoute(node: TrailblazeNode, longPress: Boolean): ActionClickPlan? {
internal fun planActionClickRoute(
node: TrailblazeNode,
longPress: Boolean,
tapRoute: TapRouteOverride? = null,
): ActionClickPlan? {
if (longPress) return null
if (tapRoute == TapRouteOverride.GESTURE) return null
val bounds = node.bounds ?: return null
val detail = node.driverDetail as? DriverNodeDetail.AndroidAccessibility ?: return null
if (ACTION_CLICK_NAME !in detail.actions) return null
if (!detail.isEnabled) return null
if (detail.isEditable) return null
if (!detail.isVisibleToUser) return null
val publishesCheckedState =
detail.isCheckable && (detail.isChecked || !detail.stateDescription.isNullOrBlank())
if (
detail.text.isNullOrBlank() &&
detail.contentDescription.isNullOrBlank() &&
!publishesCheckedState
) {
return null
if (tapRoute != TapRouteOverride.ACTION_CLICK) {
val publishesCheckedState =
detail.isCheckable && (detail.isChecked || !detail.stateDescription.isNullOrBlank())
if (
detail.text.isNullOrBlank() &&
detail.contentDescription.isNullOrBlank() &&
!publishesCheckedState
) {
return null
}
}
return ActionClickPlan(bounds, detail.className, detail.resourceId)
}
Expand Down Expand Up @@ -1246,9 +1271,13 @@ internal fun pickPreferredMatch(nodes: List<TrailblazeNode>): TrailblazeNode =
* function, kept top-level alongside [planActionClickRoute] for the same JVM-unit-testability
* reasons.
*/
internal fun describeNodeForRouteLog(node: TrailblazeNode, longPress: Boolean): String {
internal fun describeNodeForRouteLog(
node: TrailblazeNode,
longPress: Boolean,
tapRoute: TapRouteOverride? = null,
): String {
val detail = node.driverDetail as? DriverNodeDetail.AndroidAccessibility
return "longPress=$longPress, hasBounds=${node.bounds != null}, " +
return "tapRoute=$tapRoute, longPress=$longPress, hasBounds=${node.bounds != null}, " +
"className=${detail?.className}, text=${detail?.text}, contentDescription=${detail?.contentDescription}, " +
"isEnabled=${detail?.isEnabled}, isEditable=${detail?.isEditable}, " +
"isVisibleToUser=${detail?.isVisibleToUser}, isCheckable=${detail?.isCheckable}, " +
Expand Down
Loading
Loading