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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,61 @@ 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 models against the authenticated `/models` catalog and
preserves the complete server-provided ID, including plan prefixes such as
`max/qwen3.6-27b`. Router-provided `agent_model_roles` are authoritative; the
portable profiles below are compatibility fallbacks and can also be selected by
user-defined workers.

Configure 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 chooses a preferred model that is present in the logged-in account. If no
candidate is available, the worker inherits the parent model. A profile can opt
into the first catalog entry as a last resort with
`"fallback": "first-available"`.

Forked and inline skills support the same profiles:

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

An exact Verboo model can be requested without adding another API key. It is
used only when it 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
1 change: 1 addition & 0 deletions src/commands/model/model.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ mock.module('../../services/api/verbooModels.js', () => ({
clearVerbooModelsCache: () => {},
fetchVerbooModels: mock(async () => verbooModels),
getCachedVerbooModels: () => verbooModels,
getVerbooAgentModelForRole: () => undefined,
getVerbooModelMeta: (modelId: string) =>
verbooModels.find(model => model.id === modelId),
getVerbooModelReasoning: () => undefined,
Expand Down
1 change: 1 addition & 0 deletions src/components/StartupScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ async function importStartupScreenWithModels(
}))
mock.module('../services/api/verbooModels.js', () => ({
getCachedVerbooModels: () => models,
getVerbooAgentModelForRole: () => undefined,
getVerbooModelMeta: (modelId: string) =>
models.find(model => model.id === modelId),
}))
Expand Down
7 changes: 4 additions & 3 deletions src/components/tasks/AsyncAgentDetailDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,11 @@ export function AsyncAgentDetailDialog(t0) {
} else {
t11 = $[19];
}
const elapsedAndModel = agent.model ? `${elapsedTime} · ${agent.model}` : elapsedTime;
let t12;
if ($[20] !== elapsedTime || $[21] !== t10 || $[22] !== t11) {
t12 = <Text dimColor={true}>{elapsedTime}{t10}{t11}</Text>;
$[20] = elapsedTime;
if ($[20] !== elapsedAndModel || $[21] !== t10 || $[22] !== t11) {
t12 = <Text dimColor={true}>{elapsedAndModel}{t10}{t11}</Text>;
$[20] = elapsedAndModel;
$[21] = t10;
$[22] = t11;
$[23] = t12;
Expand Down
182 changes: 181 additions & 1 deletion src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
maybeLogMemoryHighWatermark,
} from './utils/memoryDiagnostics.js'
import {
createAssistantMessage,
createUserMessage,
createUserInterruptionMessage,
normalizeMessagesForAPI,
Expand Down Expand Up @@ -111,6 +112,14 @@ import {
} from './bootstrap/state.js'
import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
import { count } from './utils/array.js'
import {
createBudgetedCanUseTool,
isAgentBudgetTimeout,
markAgentBudgetCompletion,
refreshAgentBudgetDeadline,
shouldFinalizeAgentBudget,
type AgentExecutionBudgetState,
} from './query/agentExecutionBudget.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const snipModule = feature('HISTORY_SNIP')
? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
Expand All @@ -123,7 +132,18 @@ const taskSummaryModule = feature('BG_SESSIONS')
function* yieldMissingToolResultBlocks(
assistantMessages: AssistantMessage[],
errorMessage: string,
existingToolResults: Array<UserMessage | AttachmentMessage> = [],
) {
const completedToolUseIds = new Set(
existingToolResults.flatMap(message => {
if (message.type !== 'user' || !Array.isArray(message.message.content)) {
return []
}
return message.message.content.flatMap(content =>
content.type === 'tool_result' ? [content.tool_use_id] : [],
)
}),
)
for (const assistantMessage of assistantMessages) {
// Extract all tool use blocks from this assistant message
const toolUseBlocks = assistantMessage.message.content.filter(
Expand All @@ -132,6 +152,7 @@ function* yieldMissingToolResultBlocks(

// Emit an interruption message for each tool use
for (const toolUse of toolUseBlocks) {
if (completedToolUseIds.has(toolUse.id)) continue
yield createUserMessage({
content: [
{
Expand Down Expand Up @@ -190,6 +211,8 @@ export type QueryParams = {
querySource: QuerySource
maxOutputTokensOverride?: number
maxTurns?: number
/** Optional mutable state shared across an agent's foreground/background lifecycle. */
executionBudgetState?: AgentExecutionBudgetState
skipCacheWrite?: boolean
// API task_budget (output_config.task_budget, beta task-budgets-2026-03-13).
// Distinct from the tokenBudget +500k auto-continue feature. `total` is the
Expand Down Expand Up @@ -283,13 +306,20 @@ async function* queryLoop(
systemPrompt,
userContext,
systemContext,
canUseTool,
canUseTool: baseCanUseTool,
fallbackModel,
querySource,
maxTurns,
skipCacheWrite,
} = params
const deps = params.deps ?? productionDeps()
const executionBudgetState = params.executionBudgetState
const canUseTool = executionBudgetState
? createBudgetedCanUseTool(baseCanUseTool, executionBudgetState)
: baseCanUseTool
const budgetTimeoutMessage = executionBudgetState
? `Explore reached its ${Math.round(executionBudgetState.config.hardTimeoutMs / 1000)}-second time budget. Returning the partial findings collected before the deadline.`
: 'Explore reached its time budget. Returning partial findings.'

// Mutable cross-iteration state. The loop body destructures this at the top
// of each iteration so reads stay bare-name (`messages`, `toolUseContext`).
Expand Down Expand Up @@ -335,6 +365,54 @@ async function* queryLoop(

// eslint-disable-next-line no-constant-condition
while (true) {
if (executionBudgetState) {
refreshAgentBudgetDeadline(executionBudgetState)
if (maxTurns && executionBudgetState.apiCalls >= maxTurns) {
markAgentBudgetCompletion(executionBudgetState, 'max_turns')
yield createAttachmentMessage({
type: 'max_turns_reached',
maxTurns,
turnCount: executionBudgetState.apiCalls,
})
return {
reason: 'max_turns',
turnCount: executionBudgetState.apiCalls,
}
}
if (
shouldFinalizeAgentBudget(executionBudgetState, maxTurns)
) {
if (
maxTurns &&
executionBudgetState.apiCalls >= maxTurns - 1 &&
executionBudgetState.completionReason === undefined
) {
markAgentBudgetCompletion(executionBudgetState, 'max_turns')
}
executionBudgetState.finalizing = true
state = {
...state,
messages: [
...state.messages,
createUserMessage({
content:
'The Explore time budget is nearly exhausted. Do not call tools. Summarize the useful findings and explicitly identify any uncertainty.',
isMeta: true,
}),
],
toolUseContext: {
...state.toolUseContext,
options: {
...state.toolUseContext.options,
tools: [],
refreshTools: undefined,
},
},
pendingToolUseSummary: undefined,
}
}
}

// Destructure state at the top of each iteration. toolUseContext alone
// is reassigned within an iteration (queryTracking, messages updates);
// the rest are read-only between continue sites.
Expand Down Expand Up @@ -745,6 +823,21 @@ async function* queryLoop(
while (attemptWithFallback) {
attemptWithFallback = false
try {
if (executionBudgetState) {
if (maxTurns && executionBudgetState.apiCalls >= maxTurns) {
markAgentBudgetCompletion(executionBudgetState, 'max_turns')
yield createAttachmentMessage({
type: 'max_turns_reached',
maxTurns,
turnCount: executionBudgetState.apiCalls,
})
return {
reason: 'max_turns',
turnCount: executionBudgetState.apiCalls,
}
}
executionBudgetState.apiCalls++
}
let streamingFallbackOccured = false
queryCheckpoint('query_api_streaming_start')
for await (const message of deps.callModel({
Expand Down Expand Up @@ -1042,6 +1135,24 @@ async function* queryLoop(
}
}
} catch (error) {
if (isAgentBudgetTimeout(toolUseContext.abortController.signal)) {
if (streamingToolExecutor) {
for await (const update of streamingToolExecutor.getRemainingResults()) {
if (update.message) yield update.message
}
} else {
yield* yieldMissingToolResultBlocks(
assistantMessages,
'Explore time budget reached; tool execution was cancelled.',
toolResults,
)
}
yield createAssistantMessage({
content: budgetTimeoutMessage,
isVirtual: true,
})
return { reason: 'budget_timeout' }
}
logError(error)
const errorMessage =
error instanceof Error ? error.message : String(error)
Expand Down Expand Up @@ -1116,6 +1227,13 @@ async function* queryLoop(
'Interrupted by user',
)
}
if (isAgentBudgetTimeout(toolUseContext.abortController.signal)) {
yield createAssistantMessage({
content: budgetTimeoutMessage,
isVirtual: true,
})
return { reason: 'budget_timeout' }
}
// chicago MCP: auto-unhide + lock release on interrupt. Same cleanup
// as the natural turn-end path in stopHooks.ts. Main thread only —
// see stopHooks.ts for the subagent-releasing-main's-lock rationale.
Expand Down Expand Up @@ -1743,6 +1861,13 @@ async function* queryLoop(

// We were aborted during tool calls
if (toolUseContext.abortController.signal.aborted) {
if (isAgentBudgetTimeout(toolUseContext.abortController.signal)) {
yield createAssistantMessage({
content: budgetTimeoutMessage,
isVirtual: true,
})
return { reason: 'budget_timeout' }
}
// chicago MCP: auto-unhide + lock release when aborted mid-tool-call.
// This is the most likely Ctrl+C path for CU (e.g. slow screenshot).
// Main thread only — see stopHooks.ts for the subagent rationale.
Expand Down Expand Up @@ -1961,8 +2086,63 @@ async function* queryLoop(
}
}

if (executionBudgetState) {
refreshAgentBudgetDeadline(executionBudgetState)
const shouldReserveFinalTurn =
executionBudgetState.config.reserveFinalTurn &&
maxTurns !== undefined &&
executionBudgetState.apiCalls >= maxTurns - 1
const shouldFinalize = shouldFinalizeAgentBudget(
executionBudgetState,
maxTurns,
)

if (shouldFinalize) {
if (
shouldReserveFinalTurn &&
executionBudgetState.completionReason === undefined
) {
markAgentBudgetCompletion(executionBudgetState, 'max_turns')
}
executionBudgetState.finalizing = true
state = {
messages: [
...messagesForQuery,
...assistantMessages,
...toolResults,
createUserMessage({
content:
'The Explore execution budget has been reached. Do not call tools. Summarize the useful findings collected so far and explicitly identify any uncertainty.',
isMeta: true,
}),
],
toolUseContext: {
...toolUseContextWithQueryTracking,
options: {
...toolUseContextWithQueryTracking.options,
tools: [],
refreshTools: undefined,
},
},
autoCompactTracking: tracking,
turnCount: nextTurnCount,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
continuationNudgeCount: 0,
pendingToolUseSummary: undefined,
maxOutputTokensOverride: undefined,
stopHookActive,
transition: { reason: 'next_turn' },
}
continue
}
}

// Check if we've reached the max turns limit
if (maxTurns && nextTurnCount > maxTurns) {
if (executionBudgetState) {
markAgentBudgetCompletion(executionBudgetState, 'max_turns')
}
yield createAttachmentMessage({
type: 'max_turns_reached',
maxTurns,
Expand Down
Loading