Skip to content
Closed
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
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,67 @@ verboo /login

`WebFetch` works via basic HTTP plus HTML-to-markdown conversion. It may fail on JavaScript-rendered sites or sites that block plain HTTP requests.

## Agent Model Routing

Verboo resolves subagent profiles against the authenticated `/models` catalog,
so routing adapts automatically to the models included in the current account.
The built-in read-only `Explore` agent uses the `fast` profile by default.

Configure other agents in `~/.verboo/settings.json`:

```json
{
"agentRouting": {
"Explore": "fast",
"worker-review": { "profile": "review" },
"worker-backend": { "profile": "coding" },
"worker-tests": { "profile": "testing" },
"default": { "profile": "balanced" }
}
}
```

Available profiles are `fast`, `review`, `coding`, `testing`, and `balanced`. Each profile
selects the first preferred model that is actually available to the logged-in
account. When no candidate is available, routing inherits the parent model.

Skills can use the same dynamic profiles in `SKILL.md` frontmatter:

```yaml
---
name: worker-review
model: profile:review
context: fork
---
```

To opt into an unknown future model as a last resort:

```json
{
"agentRouting": {
"Explore": { "profile": "fast", "fallback": "first-available" }
}
}
```

An exact Verboo model can also be requested without configuring another API
key. The route is used only when that model exists in the authenticated catalog:

```json
{
"agentRouting": {
"worker-review": {
"model": "deepseek-v4-pro",
"provider": "inherit"
}
}
}
```

The legacy `agentModels` plus string `agentRouting` format for external
OpenAI-compatible providers remains supported.

---

## Headless gRPC Server
Expand Down
10 changes: 10 additions & 0 deletions src/query/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,14 @@ describe('resolveQueryTurnModel', () => {
}),
).toBe('max/deepseek-v4-pro')
})

test('falls back to the session model when a profile has no available candidate', () => {
expect(
resolveQueryTurnModel({
permissionMode: 'default',
turnModel: 'profile:review',
sessionModel: 'max/deepseek-v4-pro',
}),
).toBe('max/deepseek-v4-pro')
})
})
14 changes: 13 additions & 1 deletion src/query/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import {
parseAgentModelProfileReference,
resolveAgentProfileModel,
} from '../services/api/agentRouting.js'
import { getCachedVerbooModels } from '../services/api/verbooModels.js'
import {
getDefaultMainLoopModelSetting,
getRuntimeMainLoopModel,
Expand Down Expand Up @@ -26,8 +31,15 @@ export function resolveQueryTurnModel({
sessionModel,
exceeds200kTokens = false,
}: ResolveQueryTurnModelParams): string {
const turnProfile = parseAgentModelProfileReference(turnModel)
const profileModel = turnProfile
? resolveAgentProfileModel(
turnProfile,
getCachedVerbooModels() ?? [],
)
: null
const requestedModel =
turnModel ??
(turnProfile ? profileModel : turnModel) ??
parseUserSpecifiedModel(
sessionModel ?? getDefaultMainLoopModelSetting(),
)
Expand Down
204 changes: 202 additions & 2 deletions src/services/api/agentRouting.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { describe, expect, test } from 'bun:test'
import { resolveAgentProvider } from './agentRouting.js'
import type { SettingsJson } from '../../utils/settings/types.js'
import {
parseAgentModelProfileReference,
resolveAgentProfileModel,
resolveAgentProvider,
resolveAgentRoute,
} from './agentRouting.js'
import {
SettingsSchema,
type SettingsJson,
} from '../../utils/settings/types.js'
import type { VerbooModel } from './verbooModels.js'

const models = (...ids: string[]): VerbooModel[] =>
ids.map(id => ({ id, raw: { id } }))

const baseSettings = {
agentModels: {
Expand Down Expand Up @@ -123,3 +135,191 @@ describe('resolveAgentProvider', () => {
expect(result?.model).toBe('deepseek-chat')
})
})

describe('resolveAgentRoute profiles', () => {
const proCatalog = models(
'pro/qwen3.6-27b',
'pro/deepseek-v4-flash',
'pro/mimo-v2.5',
'pro/glm-4.7-flash',
)
const maxCatalog = [
...proCatalog,
...models(
'max/minimax-m3',
'max/deepseek-v4-pro',
'max/mimo-v2.5-pro',
),
]
const ultraCatalog = [
...maxCatalog,
...models('ultra/kimi-k2.7', 'ultra/glm-5.2'),
]

test('parses skill model profile references', () => {
expect(parseAgentModelProfileReference('profile:review')).toBe('review')
expect(parseAgentModelProfileReference('PROFILE:FAST')).toBe('fast')
expect(parseAgentModelProfileReference('fast')).toBeNull()
expect(parseAgentModelProfileReference('unknown-model')).toBeNull()
})

test('resolves a skill profile against the authenticated catalog', () => {
expect(resolveAgentProfileModel('review', proCatalog)).toBe(
'pro/qwen3.6-27b',
)
expect(resolveAgentProfileModel('review', maxCatalog)).toBe(
'max/deepseek-v4-pro',
)
})

test('testing profile preserves the plan-qualified Qwen model ID', () => {
expect(
resolveAgentProfileModel(
'testing',
models('max/mimo-v2.5', 'max/qwen3.6-27b'),
),
).toBe('max/qwen3.6-27b')
})

test('routes built-in Explore to fast by default in Verboo mode', () => {
const settings = {} as SettingsJson

expect(
resolveAgentRoute(undefined, 'Explore', settings, proCatalog),
).toEqual({
model: 'pro/deepseek-v4-flash',
source: 'profile',
profile: 'fast',
})
})

test.each([
['Pro', proCatalog, 'pro/deepseek-v4-flash'],
['Max', maxCatalog, 'pro/deepseek-v4-flash'],
['Ultra', ultraCatalog, 'pro/deepseek-v4-flash'],
])('selects a fast model from the %s catalog', (_plan, catalog, expected) => {
const settings = {
agentRouting: { Explore: 'fast' },
} as SettingsJson

expect(resolveAgentRoute(undefined, 'Explore', settings, catalog)).toEqual({
model: expected,
source: 'profile',
profile: 'fast',
})
})

test.each([
['Pro', proCatalog, 'pro/qwen3.6-27b'],
['Max', maxCatalog, 'max/deepseek-v4-pro'],
['Ultra', ultraCatalog, 'max/deepseek-v4-pro'],
])(
'selects the best available review model from the %s catalog',
(_plan, catalog, expected) => {
const settings = {
agentRouting: { 'worker-review': { profile: 'review' } },
} as SettingsJson

expect(
resolveAgentRoute(
undefined,
'worker-review',
settings,
catalog,
),
).toEqual({
model: expected,
source: 'profile',
profile: 'review',
})
},
)

test('preserves the exact canonical ID returned by the catalog', () => {
const settings = {
agentRouting: {
Explore: { model: 'deepseek-v4-flash', provider: 'inherit' },
},
} as SettingsJson

expect(
resolveAgentRoute(undefined, 'Explore', settings, proCatalog),
).toEqual({
model: 'pro/deepseek-v4-flash',
source: 'verboo-model',
})
})

test('inherits when an explicit model is unavailable', () => {
const settings = {
agentRouting: {
Explore: { model: 'deepseek-v4-pro', provider: 'inherit' },
},
} as SettingsJson

expect(
resolveAgentRoute(undefined, 'Explore', settings, proCatalog),
).toBeNull()
})

test('can opt into the first available model for unknown future catalogs', () => {
const futureCatalog = models('future/new-fast-model')
const settings = {
agentRouting: {
Explore: { profile: 'fast', fallback: 'first-available' },
},
} as SettingsJson

expect(
resolveAgentRoute(undefined, 'Explore', settings, futureCatalog),
).toEqual({
model: 'future/new-fast-model',
source: 'profile',
profile: 'fast',
})
})

test('keeps legacy external provider routing ahead of profile names', () => {
const settings = {
agentModels: {
fast: { base_url: 'https://fast.example.com/v1', api_key: 'secret' },
},
agentRouting: { Explore: 'fast' },
} as SettingsJson

expect(
resolveAgentRoute(undefined, 'Explore', settings, proCatalog),
).toEqual({
model: 'fast',
source: 'external-provider',
providerOverride: {
model: 'fast',
baseURL: 'https://fast.example.com/v1',
apiKey: 'secret',
},
})
})

test('settings schema accepts profile and inherited-model routes', () => {
const result = SettingsSchema().safeParse({
agentRouting: {
Explore: 'fast',
'worker-review': { profile: 'review' },
'worker-tests': { profile: 'testing' },
custom: { model: 'max/mimo-v2.5-pro', provider: 'inherit' },
},
})

expect(result.success).toBe(true)
})

test('settings schema rejects unknown profiles', () => {
const result = SettingsSchema().safeParse({
agentRouting: {
Explore: { profile: 'turbo' },
},
})

expect(result.success).toBe(false)
})
})
Loading