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
24 changes: 23 additions & 1 deletion trios/agent-server/apps/server/src/agent/ai-sdk-agent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isBrowserlessByConfiguration } from './browserless'
import { devToolsMiddleware } from '@ai-sdk/devtools'
import type {
LanguageModelV3,
Expand Down Expand Up @@ -31,8 +30,10 @@ import { buildFilesystemToolSet } from '../tools/filesystem/build-toolset'
import type { ToolContext } from '../tools/framework'
import { buildMemoryToolSet } from '../tools/memory/build-toolset'
import type { ToolRegistry } from '../tools/tool-registry'
import { isBrowserlessByConfiguration } from './browserless'
import { CHAT_MODE_ALLOWED_TOOLS } from './chat-mode'
import { createCompactionPrepareStep, type StepWithUsage } from './compaction'
import { summarizerModelFor } from './compaction/utils'
import {
buildMcpServerSpecs,
createMcpClients,
Expand Down Expand Up @@ -280,8 +281,29 @@ export class AiSdkAgent {
})

// Configure compaction for context window management
// A summary written by a fast model on the bee's own key, when the
// operator named one for this provider (see summarizerModelFor).
const summarizerName = summarizerModelFor(
config.resolvedConfig.provider,
config.resolvedConfig.model,
)
const summarizer = summarizerName
? createLanguageModel({
...config.resolvedConfig,
model: summarizerName,
})
: undefined
if (summarizerName) {
logger.info('Summaries by a separate model', {
conversationId: config.resolvedConfig.conversationId,
provider: config.resolvedConfig.provider,
beeModel: config.resolvedConfig.model,
summarizer: summarizerName,
})
}
const compactionPrepareStep = createCompactionPrepareStep({
contextWindow,
summarizer,
})
const normalizationOptions = getMessageNormalizationOptions(
config.resolvedConfig,
Expand Down
79 changes: 55 additions & 24 deletions trios/agent-server/apps/server/src/agent/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ export {

export interface CompactionConfig {
contextWindow: number
/** A faster model on the bee's own key that writes summaries first. */
summarizer?: LanguageModel
}

/**
* Ask each model in turn and keep the first summary. The summarizer is tried
* first; when it is busy or refuses, the bee's own model still gets its turn,
* so a fast summarizer can only add summaries, never take one away.
*/
async function firstSummary(
models: LanguageModel[],
summarize: (model: LanguageModel) => Promise<string | null>,
): Promise<string | null> {
for (const model of models) {
const summary = await summarize(model)
if (summary) return summary
}
return null
}

async function consumeStreamText(
Expand Down Expand Up @@ -130,7 +148,7 @@ async function summarizeTurnPrefix(
}

async function compactMessages(
model: LanguageModel,
models: LanguageModel[],
messages: ModelMessage[],
config: ComputedConfig,
state: CompactionState,
Expand Down Expand Up @@ -239,18 +257,22 @@ async function compactMessages(
if (isSplitTurn && summarizedTurnPrefix.length > 0) {
if (toSummarize.length > 0) {
const [historySummary, turnPrefixSummary] = await Promise.all([
summarizeMessages(
model,
toSummarize,
state.existingSummary,
config.summarizationTimeoutMs,
config.summarizerMaxOutputTokens,
firstSummary(models, (model) =>
summarizeMessages(
model,
toSummarize,
state.existingSummary,
config.summarizationTimeoutMs,
config.summarizerMaxOutputTokens,
),
),
summarizeTurnPrefix(
model,
summarizedTurnPrefix,
config.summarizationTimeoutMs,
turnPrefixOutputBudget,
firstSummary(models, (model) =>
summarizeTurnPrefix(
model,
summarizedTurnPrefix,
config.summarizationTimeoutMs,
turnPrefixOutputBudget,
),
),
])

Expand All @@ -260,20 +282,24 @@ async function compactMessages(
summary = historySummary ?? turnPrefixSummary
}
} else {
summary = await summarizeTurnPrefix(
model,
summarizedTurnPrefix,
config.summarizationTimeoutMs,
turnPrefixOutputBudget,
summary = await firstSummary(models, (model) =>
summarizeTurnPrefix(
model,
summarizedTurnPrefix,
config.summarizationTimeoutMs,
turnPrefixOutputBudget,
),
)
}
} else {
summary = await summarizeMessages(
model,
toSummarize,
state.existingSummary,
config.summarizationTimeoutMs,
config.summarizerMaxOutputTokens,
summary = await firstSummary(models, (model) =>
summarizeMessages(
model,
toSummarize,
state.existingSummary,
config.summarizationTimeoutMs,
config.summarizerMaxOutputTokens,
),
)
}

Expand Down Expand Up @@ -333,6 +359,8 @@ export function createCompactionPrepareStep(
minSummarizableTokens: config.minSummarizableTokens,
maxSummarizationInput: config.maxSummarizationInput,
summarizerMaxOutputTokens: config.summarizerMaxOutputTokens,
summarizationTimeoutMs: config.summarizationTimeoutMs,
summarizer: userConfig?.summarizer !== undefined,
})

return async ({
Expand Down Expand Up @@ -402,7 +430,10 @@ export function createCompactionPrepareStep(
},
)

const compacted = await compactMessages(model, reduced, config, state)
const models = userConfig?.summarizer
? [userConfig.summarizer, model]
: [model]
const compacted = await compactMessages(models, reduced, config, state)
return { messages: compacted, experimental_context: state }
}
}
47 changes: 46 additions & 1 deletion trios/agent-server/apps/server/src/agent/compaction/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,51 @@ function safeJsonStringify(value: unknown): string {
}
}

/**
* HOW LONG A SUMMARY MAY TAKE. Measured 2026-10-03 on the swarm's runners: of
* 245 compactions in fifty minutes 194 came back empty, 158 of them 59-64 s
* after they began - the fixed 60 s abort, not an answer. z-ai/glm-5.3 under
* load and the free glm-4.5-flash cannot summarize 7-30k tokens in a minute,
* and an aborted stream ends quietly, so the bee fell back to the sliding
* window and lost the start of its own work. TRIOS_COMPACTION_SUMMARIZATION_TIMEOUT_MS
* names a longer wait; anything unreadable or outside 10 s..10 min keeps 60 s.
*/
export function summarizationTimeoutMs(
env: Record<string, string | undefined> = process.env,
): number {
const raw = env.TRIOS_COMPACTION_SUMMARIZATION_TIMEOUT_MS?.trim()
const value = raw && /^\d+$/.test(raw) ? Number(raw) : Number.NaN
return value >= 10_000 && value <= 600_000
? value
: AGENT_LIMITS.COMPACTION_SUMMARIZATION_TIMEOUT_MS
}

/**
* WHICH MODEL WRITES THE SUMMARY. A summary is a short, plain task that a fast
* model does in seconds, while the bee's own model may be a slow reasoner.
* TRIOS_COMPACTION_SUMMARIZER_MODELS maps a provider to a model on the bee's
* own key and endpoint, e.g.
* `openai-compatible=nvidia/nemotron-3-super-120b-a12b`. Unset, unmapped, or
* the bee's own model: null, and the bee summarizes with its own model.
*/
export function summarizerModelFor(
provider: string,
beeModel: string,
env: Record<string, string | undefined> = process.env,
): string | null {
for (const pair of (env.TRIOS_COMPACTION_SUMMARIZER_MODELS ?? '').split(
',',
)) {
const at = pair.indexOf('=')
if (at < 1) continue
const model = pair.slice(at + 1).trim()
if (pair.slice(0, at).trim() !== provider) continue
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model)) return null
return model === beeModel ? null : model
}
return null
}

export function computeConfig(contextWindow: number): ComputedConfig {
const reserveTokens =
contextWindow <= AGENT_LIMITS.COMPACTION_SMALL_CONTEXT_WINDOW
Expand Down Expand Up @@ -117,7 +162,7 @@ export function computeConfig(contextWindow: number): ComputedConfig {
minSummarizableTokens,
maxSummarizationInput,
summarizerMaxOutputTokens,
summarizationTimeoutMs: AGENT_LIMITS.COMPACTION_SUMMARIZATION_TIMEOUT_MS,
summarizationTimeoutMs: summarizationTimeoutMs(),
fixedOverhead,
safetyMultiplier: AGENT_LIMITS.COMPACTION_SAFETY_MULTIPLIER,
imageTokenEstimate: AGENT_LIMITS.COMPACTION_IMAGE_TOKEN_ESTIMATE,
Expand Down
123 changes: 112 additions & 11 deletions trios/agent-server/apps/server/src/api/services/queen-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type { Pool } from 'pg'
import { createQueenPool } from '../../lib/db/queen-pool'
import { logger } from '../../lib/logger'
import { bundleOfBranch } from '../routes/queen-export'
import { runClaimedBee } from './queen-dispatch'
import { runClaimedBee, salvageDispatch } from './queen-dispatch'
import { queenLeaseDatabaseUrl } from './queen-lease'

export interface BeeOrder {
Expand Down Expand Up @@ -221,16 +221,112 @@ export async function runOneOrder(
issue: order.issue,
branch: order.branch,
})
const outcome = await runClaimedBee(pool, order)
if (!outcome.started) return order
await waitForEnding(pool, order, runner)
await storeBundle(pool, order.issue, runner).catch((error) => {
logger.warn('Runner could not store the bundle of a finished bee', {
issue: order.issue,
error: error instanceof Error ? error.message : String(error),
inFlight.set(order.issue, order)
try {
const outcome = await runClaimedBee(pool, order)
if (!outcome.started) return order
await waitForEnding(pool, order, runner)
await storeBundle(pool, order.issue, runner).catch((error) => {
logger.warn('Runner could not store the bundle of a finished bee', {
issue: order.issue,
error: error instanceof Error ? error.message : String(error),
})
})
})
return order
return order
} finally {
inFlight.delete(order.issue)
}
}

/** The orders this process is carrying right now, by issue. */
const inFlight = new Map<number, BeeOrder>()

/** The runner this process started, so a shutdown can drain it. */
let active: { pool: Pool; runner: string; stop: () => void } | null = null

/**
* HOW LONG A STOPPING RUNNER WAITS FOR ITS BEES. Measured 2026-10-03: the
* server leaves about 2 s after SIGTERM, so every release killed every bee in
* flight - thirty at a time, each up to an hour of work - and their issues
* waited for the reaper. `TRIOS_BEE_RUNNER_DRAIN_SECONDS` names the wait
* (0..3600; unset or unreadable is 0, the old behaviour). The platform must
* allow it too: on Railway, RAILWAY_DEPLOYMENT_DRAINING_SECONDS at least as long.
*/
export function drainSeconds(
env: Record<string, string | undefined> = process.env,
): number {
const raw = env.TRIOS_BEE_RUNNER_DRAIN_SECONDS?.trim() ?? ''
if (!/^\d+$/.test(raw)) return 0
return Math.min(Number(raw), 3600)
}

export interface DrainDeps {
seconds?: number
now?: () => number
sleep?: (ms: number) => Promise<void>
salvage?: typeof salvageDispatch
store?: typeof storeBundle
orders?: Map<number, BeeOrder>
}

/**
* Take no more orders and let the bees in flight end on their own, up to the
* drain deadline. A bee still running at the deadline is not thrown away: its
* uncommitted work is salvaged into its branch and the branch is stored as a
* bundle, so the Queen reviews what it did and the next attempt starts from it.
*/
export async function drainBeeRunner(
pool: Pool,
runner: string,
deps: DrainDeps = {},
): Promise<{ waited: number; saved: number[] }> {
const orders = deps.orders ?? inFlight
const seconds = deps.seconds ?? drainSeconds()
const now = deps.now ?? Date.now
const sleep =
deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
const started = now()
const deadline = started + seconds * 1000
if (orders.size > 0) {
logger.info('Runner draining: no new orders, waiting for its bees', {
runner,
bees: orders.size,
seconds,
})
}
while (orders.size > 0 && now() < deadline) {
await sleep(Math.min(2000, Math.max(0, deadline - now())))
}
const saved: number[] = []
for (const order of [...orders.values()]) {
try {
await (deps.salvage ?? salvageDispatch)(pool, order.issue, 'reaped', {
conversationId: order.conversationId,
})
await (deps.store ?? storeBundle)(pool, order.issue, runner)
saved.push(order.issue)
} catch (error) {
logger.warn('Runner could not save the branch of a bee it stopped', {
issue: order.issue,
error: error instanceof Error ? error.message : String(error),
})
}
}
if (saved.length > 0) {
logger.info('Runner saved the branches of bees it could not wait for', {
runner,
issues: saved,
})
}
return { waited: Math.round((now() - started) / 1000), saved }
}

/** For the server's shutdown: drain the runner this process started, if any. */
export async function drainActiveRunner(): Promise<void> {
if (!active) return
active.stop()
if (drainSeconds() === 0) return
await drainBeeRunner(active.pool, active.runner)
}

/**
Expand Down Expand Up @@ -283,9 +379,14 @@ export function startBeeRunner(): void {
}, every * 1000)
take()

process.once('SIGTERM', () => {
const stop = (): void => {
stopped = true
clearInterval(timer)
}
active = { pool, runner, stop }

process.once('SIGTERM', () => {
stop()
// The bees in flight are NOT abandoned here. Their rows stay claimed and
// unfinished, which is exactly what a Queen that died mid-bee always left,
// and the boot reaper clears it the same way.
Expand Down
5 changes: 4 additions & 1 deletion trios/agent-server/apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
configureVmRuntime,
peekOpenClawService,
} from './api/services/openclaw/openclaw-service'
import { startBeeRunner } from './api/services/queen-runner'
import { drainActiveRunner, startBeeRunner } from './api/services/queen-runner'
import { startQueenTick } from './api/services/queen-tick'
import { CdpBackend } from './browser/backends/cdp'
import { Browser } from './browser/browser'
Expand Down Expand Up @@ -249,6 +249,9 @@ export class Application {

async stop(reason?: string): Promise<void> {
logger.info('Shutting down server...', { reason })
// Bees first, while the database and the HTTP server are still up: a
// runner given a drain window lets its bees end before anything closes.
if (reason === 'SIGTERM') await drainActiveRunner().catch(() => {})
stopSkillSync()
// Only what was actually started. `getOpenClawService` constructs on
// demand, so calling it here built the runtime it was meant to stop.
Expand Down
Loading
Loading