feat: per-model max output tokens and a conversation-time thinking level backed by agent settings (#28)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-22 22:22:41 +08:00
committed by GitHub
parent 99f391f379
commit cabbab1c16
36 changed files with 797 additions and 42 deletions
+21 -5
View File
@@ -117,6 +117,16 @@ export function effectiveMaxContextLength(configured: number, contextWindow: unk
return Math.min(configured, Math.floor(contextWindow * 0.75));
}
/**
* Output cap for meta requests (title generation / vision describing): these carry their own
* small hardcoded budget, tightened further by the entry's per-model `max_tokens` when that is
* smaller — a cap the user pinned below the budget must bind every request to that model. The
* budget is never raised.
*/
export function metaMaxTokens(budget: number, modelCap: number | undefined): number {
return modelCap !== undefined ? Math.min(budget, modelCap) : budget;
}
/** Create or load an Agent. */
export async function createAgent(opts: CreateAgentOptions = {}): Promise<Agent> {
const state = await loadOrInitAgentState(opts);
@@ -570,7 +580,8 @@ export class Agent {
: {}),
tools: [],
thinkingLevel: "none",
maxTokens: 2048,
// The describing budget, tightened by the vision entry's own pinned cap when smaller.
maxTokens: metaMaxTokens(2048, visionEntry.max_tokens),
requestTimeoutMs: 60_000,
}),
};
@@ -592,6 +603,12 @@ export class Agent {
});
const tools = await environment.listTools();
// Effective output cap: the entry's per-model annotation wins over the Agent's
// system_config value — the fit is a model trait: the seeded per-Agent default (32000)
// cannot fit into e.g. a 32768-token context window together with any prompt, so a
// small-window model needs its own pinned cap. Unset inherits the Agent value.
const maxTokens = modelEntry.max_tokens ?? this.state.systemConfig.model?.max_tokens;
// LLM constructor args are extracted into a constant so they can be reused as-is when
// rebuilding a new LLM object after compaction (with a fresh model context) — the system
// prompt and tool definitions aren't part of the compacted history, so the new object keeps
@@ -612,9 +629,7 @@ export class Agent {
...(modelEntry.context_window !== undefined
? { contextWindow: modelEntry.context_window }
: {}),
...(this.state.systemConfig.model?.max_tokens !== undefined
? { maxTokens: this.state.systemConfig.model.max_tokens }
: {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(this.state.systemConfig.model?.thinking_level !== undefined
? { thinkingLevel: this.state.systemConfig.model.thinking_level }
: {}),
@@ -640,7 +655,8 @@ export class Agent {
...(modelEntry.client_type !== undefined ? { clientType: modelEntry.client_type } : {}),
tools: [],
thinkingLevel: "none",
maxTokens: 300,
// The meta budget, tightened by the entry's pinned per-model cap when smaller.
maxTokens: metaMaxTokens(300, modelEntry.max_tokens),
requestTimeoutMs: 30_000,
});
+7 -7
View File
@@ -40,13 +40,13 @@ export type {
} from "./engine/context-engine.js";
export { Session } from "./session.js";
export type { SessionConfig } from "./session.js";
export {
buildTitlePrompt,
generateTitleWithLLM,
sanitizeTitle,
stripConversationMarkers,
} from "./session-title.js";
export type { SessionTitleResult } from "./session-title.js";
// Session-title generation lives in internal/ (an assembly detail of Session.generateTitle);
// only its narrow public surface is re-exported: the result type (part of
// Session.generateTitle's signature) and the sanitation helpers the Web server's title
// fallback builds on (stripConversationMarkers / sanitizeTitle). The prompt/request
// internals (buildTitlePrompt / generateTitleWithLLM) are deliberately not public.
export { sanitizeTitle, stripConversationMarkers } from "./internal/session-title.js";
export type { SessionTitleResult } from "./internal/session-title.js";
export { Agent, createAgent } from "./agent.js";
export type { CreateAgentOptions, CreateSessionOptions, ResumeSessionOptions } from "./agent.js";
@@ -1,21 +1,25 @@
/**
* Session title generation: an **out-of-band, one-off request** that generates
* a short title from the first-turn conversation text.
* a short title from the first-turn conversation text (used by `Session.generateTitle`
* for assembly, not exported wholesale via the barrel).
*
* Called by `session.generateTitle()`: sends one request using the bare LLM for the session's
* Model (no tools, no system prompt, thinking off), without writing history or Trace. Material
* defaults to what the Session self-captures during run (see session.ts); this module is only
* responsible for the prompt format, driving the one-off request, and sanitizing the result —
* when to generate a title and where to store it is decided by the host (Web server / CLI).
* The narrow public surface — `SessionTitleResult` (part of `Session.generateTitle`'s
* signature) and the sanitation helpers the host's title fallback builds on — is re-exported
* by the barrel; the prompt/request internals are not.
*/
import { userText } from "./omnimessage/index.js";
import { userText } from "../omnimessage/index.js";
import type {
OmniMessage,
TextPayload,
TokenCounts,
TokenUsagePayload,
} from "./omnimessage/index.js";
import type { LLMInterface } from "./interfaces.js";
} from "../omnimessage/index.js";
import type { LLMInterface } from "../interfaces.js";
/** Cap on conversation text spliced into the title request (user/model each truncated separately, to control cost). */
const EXCERPT_MAX_CHARS = 2000;
+2 -2
View File
@@ -21,8 +21,8 @@ import { sessionMeta } from "./omnimessage/index.js";
import type { OmniMessage, SessionMetaPayload, TokenCounts } from "./omnimessage/index.js";
import { imagesToScratchpadPaths } from "./internal/session-support.js";
import type { EnvironmentInterface, LLMInterface, ToolPermission } from "./interfaces.js";
import { generateTitleWithLLM } from "./session-title.js";
import type { SessionTitleResult } from "./session-title.js";
import { generateTitleWithLLM } from "./internal/session-title.js";
import type { SessionTitleResult } from "./internal/session-title.js";
import { ContextEngine } from "./engine/context-engine.js";
import type {
CompactAvailability,
+14
View File
@@ -88,6 +88,14 @@ export interface ModelEntry {
* enters that session's history.
*/
vision?: boolean;
/**
* Per-model max output tokens (the request's output cap, i.e. GenerativeModelConfig.maxTokens):
* when set it wins over the Agent's `system_config.model.max_tokens` — the fit is a model trait
* (the seeded per-Agent default of 32000 cannot fit into e.g. a 32768-token context window
* together with any prompt, and the upstream rejects the request outright). Unset = inherit
* the Agent value. User-only, never preset by the builtin catalog.
*/
max_tokens?: number;
/** Pricing info; absent means this Model's cost isn't counted. */
pricing?: ModelPricing;
/** API key (inlined credential); left empty falls back to the vendor's environment variable. */
@@ -275,6 +283,8 @@ export async function addModel(
client_type?: string;
/** Whether image input is supported (vision/multimodal); keeps the existing value by default (treated as supported if never set). */
vision?: boolean;
/** Per-model max output tokens (wins over the Agent config); keeps the existing value by default (unset = inherit the Agent value). */
max_tokens?: number;
/** Price input may cover only some buckets; merged and written as a complete `ModelPricing`. */
pricing?: Partial<ModelPricing>;
api_key?: string;
@@ -310,6 +320,10 @@ export async function addModel(
if (vision !== undefined) {
modelEntry.vision = vision;
}
const maxTokens = entry.max_tokens ?? existing?.max_tokens;
if (maxTokens !== undefined) {
modelEntry.max_tokens = maxTokens;
}
// The three price buckets are merged field by field: an unspecified bucket keeps its existing
// value (the same policy as context_window/credential); the unit is fixed to usd_per_mtok, and
// the complete pricing is written as long as any bucket is present.