/** * Agent and the `createAgent` entry point. * * `createAgent` is the unified way to create/load an Agent: it initializes Agent State * if the directory is empty, otherwise loads by agentId. * An Agent has exactly one Agent State and can run multiple times; the * Workspace is determined when a Session is created. */ import fs from "node:fs/promises"; import path from "node:path"; import { assertValidId, assembleSystemPrompt, buildToolConfig, selectBuiltinToolsForModel, DEFAULT_COMPACTION_PROMPT, formatModelRef, getModel, listInstalledSkills, loadAgentVault, loadOrInitAgentState, loadProjectConfig, projectDir, goalFilePath, resolveModelRef, scratchpadDir, systemConfigPath, tracesDir, type AgentState, type ModelRef, type ProjectConfig, } from "./state/index.js"; import { GenerativeModel, ToolCallIdAllocator } from "./llm/index.js"; import { Environment } from "./environment/index.js"; import { Writer, findLatestTraceFile, latestSessionId as latestTraceSessionId, readTraceTolerant, resumeTrace, } from "./trace/index.js"; import { Session } from "./session.js"; import { createTempWorkspace, formatSessionId, sessionEnvironment, } from "./internal/session-support.js"; import { userText, withOrigin } from "./omnimessage/index.js"; import type { MessageOrigin, OmniMessage, TokenCounts, ToolCallPayload, } from "./omnimessage/index.js"; import { SUBAGENT_NAME } from "./environment/tools/run-subagent.js"; import { INPUT_SUBAGENT_NAME } from "./environment/tools/input-subagent.js"; import type { CompactionSettings } from "./engine/context-engine.js"; import type { GenerativeModelConfig, SubagentRunner, ThinkingLevelName, ToolDefinition, VisionDescriberService, } from "./interfaces.js"; import type { ModelEntry } from "./state/index.js"; /** * Maximum subagent spawn depth. Currently capped at 1 level (a subagent cannot spawn * another subagent); the depth mechanism is designed to support multiple levels — * raise this constant to allow deeper nesting. */ const MAX_SUBAGENT_DEPTH = 1; export interface CreateAgentOptions { agentId?: string; projectId?: string; /** Local data root directory; defaults to `resolveRoot()` (PENGUIN_HOME or ~/.penguin/data). */ root?: string; } export interface CreateSessionOptions { /** Workspace for this run; if unspecified, a temporary Workspace is created under the Agent directory. */ workspaceDir?: string; /** * Model used for this Session (upstream model_id); must be given together with `provider`. * Omit both to use the Project's default Model. */ modelId?: string; /** * Provider grouping for `modelId`: the two together form the paired reference. It is never * inferred, so it's "both or neither" — either half alone is an error, not a lookup. */ provider?: string; /** * Thinking level for this Session — a tri-state: * - a `ThinkingLevelName` pins the level; * - `undefined` (omitted) falls back to the Agent config's `model.thinking_level`; * - `null` means "no thinking level": the config fallback is suppressed entirely * (nothing goes into the LLM config; session_meta echoes `"default"`). * Subagent spawning always passes the parent Session's effective level (its level, or * `null` when the parent has none), so a child Session thinks at the parent's level and * never falls back to the child Agent's own config. */ thinkingLevel?: ThinkingLevelName | null; /** Explicit credentials; if unspecified, falls back to credentials in the Project config, then to AgentHub reading environment variables. */ apiKey?: string; baseUrl?: string; /** Internal use: this Session's depth in the subagent spawn chain (0 at the top level), used to cap spawn depth. */ subagentDepth?: number; /** Session origin recorded in session_meta (absent = user-created); the subagent spawn site passes "subagent", callers driven by a scheduled task pass "schedule". */ source?: "subagent" | "schedule"; } export interface ResumeSessionOptions { /** Id of the Session to resume. */ sessionId: string; /** Explicit credentials; if unspecified, falls back to credentials in the Project config, then to AgentHub reading environment variables. */ apiKey?: string; baseUrl?: string; } /** * Effective compaction threshold: capped at 75% of the model's `context_window` — * the threshold must stay well below the hard window limit, otherwise small-window * models get rejected by the provider (a non-retryable 400) before compaction even * triggers, and the compaction request itself (old context + prompt + summary output) * also needs headroom. Not clamped when `<=0` (disabled) or the window is unknown. */ export function effectiveMaxContextLength(configured: number, contextWindow: unknown): number { if (configured <= 0) return configured; if (typeof contextWindow !== "number") return configured; 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. A non-positive cap (-1 = uncapped) never tightens: Math.min against * it would send max_tokens -1 on the wire, and meta requests must keep their small budget. */ export function metaMaxTokens(budget: number, modelCap: number | undefined): number { return modelCap !== undefined && modelCap > 0 ? Math.min(budget, modelCap) : budget; } /** Create or load an Agent. */ export async function createAgent(opts: CreateAgentOptions = {}): Promise { const state = await loadOrInitAgentState(opts); const projectConfig = await loadProjectConfig(state.root, state.projectId); return new Agent(state, projectConfig); } export class Agent { constructor( readonly state: AgentState, readonly projectConfig: ProjectConfig, ) {} /** * Create a Session in the specified (or a temporary) Workspace. * Docs: /docs/sessions-and-traces § "Run model". */ async createSession(opts: CreateSessionOptions = {}): Promise { // Model is validated first (before creating the Workspace, so failure leaves no // temp directory behind): the reference must be the complete (provider, model_id) // pair — the config's unique key — and must name an entry in the Project config; a // reference outside the config throws immediately rather than passing silently, // otherwise credentials, pricing, and the context window would all be unavailable. // Half a reference is always an error: the missing half is never inferred, since a // guessed provider would send the entry's credential to a vendor nobody named. if ((opts.modelId === undefined) !== (opts.provider === undefined)) { throw new Error( "A model reference must be given as a (provider, model_id) pair: both must be specified, or neither (to use the Project's default model).", ); } let ref: ModelRef; if (opts.modelId !== undefined && opts.provider !== undefined) { // Pair validation only (resolveModelRef): the pair either names a configured entry or errors. ref = resolveModelRef(this.projectConfig, opts.modelId, opts.provider); } else if (this.projectConfig.default_model) { ref = this.projectConfig.default_model; } else { throw new Error( "No modelId was specified and the Project config has no default_model. Use `penguin config model add/default` to set the default model.", ); } const modelEntry = getModel(this.projectConfig, ref); if (!modelEntry) { throw new Error( `Model is not in the Project config: ${formatModelRef(ref)}. Use \`penguin config model list\` to see the configured models, or \`penguin config model add\` to add one.`, ); } // Credentials are inlined on the model entry (single config file); an // explicit argument takes priority, falling back to AgentHub reading env vars // when both are absent. const apiKey = opts.apiKey ?? modelEntry.api_key; const baseUrl = opts.baseUrl ?? modelEntry.base_url; // An explicit Workspace must already exist as a directory: if it // doesn't, throw rather than auto-create (to avoid a typo silently working in // the wrong location); a temp Workspace is only created when unspecified. let workspaceDir: string; if (opts.workspaceDir) { workspaceDir = path.resolve(opts.workspaceDir); let stat; try { stat = await fs.stat(workspaceDir); } catch { throw new Error( `Workspace does not exist: ${workspaceDir}. Specify an existing directory, or omit the Workspace to use a temporary directory.`, ); } if (!stat.isDirectory()) { throw new Error(`Workspace is not a directory: ${workspaceDir}.`); } } else { workspaceDir = await createTempWorkspace( this.state.root, this.state.projectId, this.state.agentId, ); } const sessionId = formatSessionId(); const subagentDepth = opts.subagentDepth ?? 0; // Effective thinking level (tri-state option): a value wins over this Agent's own // system_config; `null` — how subagent spawning says "the parent has none" — suppresses // the config fallback entirely; only `undefined` (no option) reads the Agent config. const thinkingLevel = opts.thinkingLevel === null ? undefined : (opts.thinkingLevel ?? this.state.systemConfig.model?.thinking_level); // Agent-level vault (agent_state/.vault.toml) and installed Skills: read the current values each time a Session is created. const vault = await loadAgentVault(this.state.root, this.state.projectId, this.state.agentId); const installedSkills = await listInstalledSkills( this.state.root, this.state.projectId, this.state.agentId, ); // The assembled system prompt goes both to the LLM and into session_meta (so the // Trace can audit the actual effective value). The vault only injects **key names** // into the prompt (so the model knows which API keys are available); values only // go into the subprocess environment. Skills only inject metadata (name and // description); the model reads the body on demand via shell. const systemPrompt = assembleSystemPrompt( this.state, sessionEnvironment(workspaceDir, sessionId, { agentId: this.state.agentId, projectDir: projectDir(this.state.root, this.state.projectId), provider: modelEntry.provider, modelId: modelEntry.model_id, }), Object.keys(vault), installedSkills, ); const rt = await this.buildRuntime({ workspaceDir, modelEntry, apiKey, baseUrl, systemPrompt, thinkingLevel, subagentDepth, vault, }); const trace = new Writer({ tracesDir: tracesDir(this.state.root, this.state.projectId, this.state.agentId), sessionId, }); return new Session({ // session_meta holds per-session invariants only: the thinking level is a per-turn // run parameter (RunOptions.thinkingLevel) and is deliberately not recorded here. meta: { session_id: sessionId, provider: modelEntry.provider, model_id: modelEntry.model_id, model_context_window: modelEntry.context_window ?? "unknown", system_prompt: systemPrompt, tools: rt.tools, agent_state: this.state.stateDir, workspace: workspaceDir, ...(opts.source !== undefined ? { source: opts.source } : {}), }, llm: rt.llm, environment: rt.environment, trace, createLLM: rt.createLLM, createBareLLM: rt.createBareLLM, compaction: rt.compaction, // Model doesn't support images: input images are written to the session scratchpad and their paths appended to the text (viewed via describe_image). ...(modelEntry.vision === false ? { inputImagesDir: path.join( scratchpadDir(this.state.root, this.state.projectId, this.state.agentId), sessionId, ), } : {}), // Goal mode's control file lives in the session scratchpad; the path is fixed per // Session, so it is wired here rather than passed per-run. goalFilePath: goalFilePath( this.state.root, this.state.projectId, this.state.agentId, sessionId, ), // Max turns comes from the Agent's system_config (runtime parameters belong to the Agent config). ...(this.state.systemConfig.max_turns !== undefined ? { maxTurns: this.state.systemConfig.max_turns } : {}), }); } /** * Resume an existing Session and continue the conversation. * * The resume source is the Session's **latest-index** Trace file: runtime config is * read from its `session_meta` (Model, the original system prompt text, and the * Workspace all carry over from the original Session and cannot be changed), while * tools and Environment are reassembled from the current Agent State. The replayed, * already-committed history is injected once via AgentHub's setHistory (used only on * resume); any leftover input is rebuilt as carry-over (paired fallback placeholders * are synthesized in memory only, never written to the Trace). Messages after resume * continue in the original Trace file (the file follows the context, not the date), * and Token / turn-count stats carry over from their original values. * Docs: /docs/sessions-and-traces § "Session recovery". */ async resumeSession(opts: ResumeSessionOptions): Promise { const { sessionId } = opts; const dir = tracesDir(this.state.root, this.state.projectId, this.state.agentId); const located = await findLatestTraceFile(dir, sessionId); if (!located) { throw new Error( `Session does not exist: ${sessionId} (no matching Trace file found under ${dir}).`, ); } const resumed = resumeTrace(await readTraceTolerant(located.path)); if (!resumed.meta) { throw new Error(`Trace is missing session_meta and cannot be resumed: ${located.path}`); } const meta = resumed.meta.payload; // Model reference is stored as a pair in session_meta; a missing provider means legacy data (no migration since the product hasn't shipped yet). if (typeof meta.provider !== "string") { throw new Error( `Trace is from legacy data (session_meta is missing provider; the model reference is not split into separate fields): ${located.path}. Delete the data directory and recreate the Session.`, ); } // The Workspace carries over from the original Session and must still exist (throw if missing, never auto-create). const workspaceDir = meta.workspace; let stat; try { stat = await fs.stat(workspaceDir); } catch { throw new Error( `The original Session's Workspace no longer exists: ${workspaceDir}; cannot resume.`, ); } if (!stat.isDirectory()) { throw new Error( `The original Session's Workspace is not a directory: ${workspaceDir}; cannot resume.`, ); } // The Model carries over from the original Session (paired reference) and must still be present in the Project config. const ref: ModelRef = { provider: meta.provider, model_id: meta.model_id }; const modelEntry = getModel(this.projectConfig, ref); if (!modelEntry) { throw new Error( `The original Session's Model is not in the Project config: ${formatModelRef(ref)}. Use \`penguin config model add\` to configure it again before resuming.`, ); } const apiKey = opts.apiKey ?? modelEntry.api_key; const baseUrl = opts.baseUrl ?? modelEntry.base_url; // Tools and Environment are reassembled from the current Agent State (tool // definitions are passed with every Request and aren't part of the history); the // system prompt uses the original text recorded in the Trace (identical to the // original history); the vault uses current values (it's injected into the // subprocess environment, not the history, so a resumed Session should get the // latest keys too). // The default thinking level comes from this Agent's current config only: session_meta // no longer records one (it became a per-turn run parameter), and a `thinking_level` // still present in a legacy Trace's meta JSON is deliberately ignored — a resumed // legacy subagent session falls back to this Agent's configured level instead of // keeping the level it inherited at spawn time. const thinkingLevel = this.state.systemConfig.model?.thinking_level; const rt = await this.buildRuntime({ workspaceDir, modelEntry, apiKey, baseUrl, systemPrompt: meta.system_prompt, thinkingLevel, subagentDepth: 0, vault: await loadAgentVault(this.state.root, this.state.projectId, this.state.agentId), }); // History is injected once into a fresh context object (setHistory is only used // on resume); Session cumulative Token counts carry over. Wrap the error // descriptively: bad tool arguments in the history (e.g. truncated JSON written by // a third-party OpenAI-compatible endpoint) throw a raw SyntaxError during // conversion, so the error must indicate Trace history corruption rather than a // regular runtime error. if (resumed.history.length > 0) { try { rt.llm.setHistory(resumed.history); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new Error( `Resume failed: the Trace history could not be injected (records may be corrupted, e.g. invalid tool-argument JSON): ${detail}`, ); } } rt.llm.sessionTokens = resumed.sessionTokens; // Continue writing to the original Trace file (the Trace only records real messages; synthesized paired placeholders are re-emitted in memory alongside carry-over). const trace = new Writer({ tracesDir: dir, sessionId, dateDir: located.dateDir, startIndex: located.index, }); return new Session({ // Invariants only — meta writes never contain a thinking level. meta: { session_id: sessionId, provider: modelEntry.provider, model_id: modelEntry.model_id, model_context_window: modelEntry.context_window ?? "unknown", system_prompt: meta.system_prompt, tools: rt.tools, agent_state: this.state.stateDir, workspace: workspaceDir, // The origin carries over from the original session_meta (a resumed scheduled/subagent // Session stays marked). The on-disk value is untrusted: only the exact known origins // pass; junk written by a third party is dropped rather than cast through. ...(meta.source === "subagent" || meta.source === "schedule" ? { source: meta.source } : {}), }, llm: rt.llm, environment: rt.environment, trace, createLLM: rt.createLLM, createBareLLM: rt.createBareLLM, compaction: rt.compaction, // Model doesn't support images: input images are written to the session scratchpad and their paths appended to the text (viewed via describe_image). ...(modelEntry.vision === false ? { inputImagesDir: path.join( scratchpadDir(this.state.root, this.state.projectId, this.state.agentId), sessionId, ), } : {}), // Goal mode's control file lives in the session scratchpad; the path is fixed per // Session, so it is wired here rather than passed per-run. goalFilePath: goalFilePath( this.state.root, this.state.projectId, this.state.agentId, sessionId, ), ...(this.state.systemConfig.max_turns !== undefined ? { maxTurns: this.state.systemConfig.max_turns } : {}), // session_meta is already in the original Trace file, so it isn't rewritten; on the first write after a compaction-triggered rotation, the file is split first. metaAlreadyWritten: true, initialEngineState: { carryOver: resumed.carryOver, ...(resumed.pendingSummary ? { pendingSummary: resumed.pendingSummary } : {}), sessionTurns: resumed.sessionTurns, sessionTokens: resumed.sessionTokens, lastRequestTotal: resumed.lastRequestTotal, pendingTraceRotation: resumed.contextClosed, }, resumedHistory: resumed.renderMessages, }); } /** Id of the most recent Session under the current Agent (determined by the timestamp in session_id); returns null if there is no Session. */ async latestSessionId(): Promise { return latestTraceSessionId( tracesDir(this.state.root, this.state.projectId, this.state.agentId), ); } /** * Assemble a Session's runtime components (shared by createSession and * resumeSession): the child-Agent runner, Environment and tools, the LLM object * and its post-compaction rebuild factory, and the compaction config. */ private async buildRuntime(args: { workspaceDir: string; /** This Session's Model entry: the caller (createSession / resumeSession) has already validated it exists in the config. */ modelEntry: ModelEntry; apiKey: string | undefined; baseUrl: string | undefined; systemPrompt: string; /** The Session's effective thinking level: the caller resolves the explicit option against the Agent config. */ thinkingLevel: ThinkingLevelName | undefined; subagentDepth: number; vault: Record; }): Promise<{ environment: Environment; tools: ToolDefinition[]; llm: GenerativeModel; createLLM: (sessionTokens: TokenCounts) => GenerativeModel; createBareLLM: () => GenerativeModel; compaction: CompactionSettings; }> { const { workspaceDir, modelEntry, apiKey, baseUrl, systemPrompt, thinkingLevel, subagentDepth, vault, } = args; // Child-Agent runner: injected into the run_subagent tool so it doesn't need to // depend on Agent/Session (breaking a circular dependency). The model can // optionally choose agentId (omitted = call the current Agent) and the child // Session's model (omitted = the parent Session's model); an explicit model reference // is forwarded to createSession as-is and must be a complete (provider, model_id) // pair — half a reference is rejected there rather than being guessed here. Precheck // errors (depth limit exceeded / agent doesn't exist) are expressed as throws, which // the Environment collapses to failed. // Docs: /docs/interfaces § "Subagent interfaces" const parentAgent = this; const { root, projectId, agentId: parentAgentId } = this.state; const subagentRunner: SubagentRunner = { // Spawn and run are separate: the same child Session can run for multiple turns // (continuing via input_subagent appending a prompt); resource cleanup is // consolidated in handle.dispose (called by the managing ManagedSubagentSession). async spawn({ agentId, modelId, provider }) { if (subagentDepth >= MAX_SUBAGENT_DEPTH) { throw new Error( `subagent depth limit ${MAX_SUBAGENT_DEPTH} reached; not spawning another subagent`, ); } if (agentId !== undefined && agentId !== parentAgentId) { try { assertValidId("agent_id", agentId); await fs.access(systemConfigPath(root, projectId, agentId)); } catch { throw new Error( `subagent error: agent "${agentId}" does not exist or is not accessible`, ); } } const childAgent = agentId !== undefined && agentId !== parentAgentId ? await createAgent({ root, projectId, agentId }) : parentAgent; // The child Session follows the PARENT Session, never the Project default: with the // model pair fully omitted it reuses the parent's resolved (provider, model_id) — // the same Project-config entry, so max_tokens / context_window / vision follow // automatically. An explicit pair still wins, and half a pair is forwarded as-is so // createSession rejects it (never silently completed from the parent's here). const childModel = modelId === undefined && provider === undefined ? { modelId: modelEntry.model_id, provider: modelEntry.provider } : { ...(modelId !== undefined ? { modelId } : {}), ...(provider !== undefined ? { provider } : {}), }; // The parent Session's effective thinking level (`thinkingLevel` in scope) is passed // down too, as a tri-state: `null` when the parent has none, so the child never falls // back to its own Agent config (which would otherwise apply on a cross-agent spawn). const childSession = await childAgent.createSession({ workspaceDir, ...childModel, thinkingLevel: thinkingLevel ?? null, subagentDepth: subagentDepth + 1, source: "subagent", }); // All child-session messages are tagged with an origin (the child Session id, // prepended as one hop from outer to inner); the first turn forwards the // child's session_meta first (including agent_state and other metadata) so the // parent frontend can recognize the nested session (for rendering, stats, // approval visibility); the parent Trace skips these accordingly (the child // Session has its own Trace, linked by session id). const hop: MessageOrigin = childSession.sessionId; let metaSent = false; return { sessionId: hop, async *run({ prompt, signal, approve }) { if (!metaSent) { metaSent = true; yield withOrigin(childSession.metaMessage, hop); } // Pass through the parent's approval callback: the child Session inherits // the parent Agent's approval mode (with no callback, the child engine // defaults to deny). The tool_call received for approval also carries the // origin, so the approval UI can identify which tool a subagent is calling. const childApprove = approve ? (tc: OmniMessage) => approve(withOrigin(tc, hop)) : undefined; // The engine writes a run's input to the CHILD's own Trace but never replays // it to its consumer (a session's normal caller typed that input itself) — // here the consumer is the PARENT, whose frontend has never seen the child's // prompt. Forward the input message itself (origin-tagged, ahead of the run's // output) so the live nested view shows the child's user side exactly like a // reloaded one (history expansion splices the child Trace, which carries this // same message). The parent engine drops origin messages from the parent // Trace, so replay never duplicates it. Later rounds (input_subagent // follow-up prompts) come through this same generator and are forwarded the // same way. const input = userText(prompt); yield withOrigin(input, hop); for await (const msg of childSession.run([input], { ...(signal ? { signal } : {}), ...(childApprove ? { approve: childApprove } : {}), })) { yield withOrigin(msg, hop); } }, dispose() { childSession.dispose(); }, }; }, }; // Tool exposure is capped by depth: a (leaf) child Agent that has reached the // max spawn depth no longer gets run_subagent or input_subagent (the latter // depends on the subagent_id produced by the former, so exposing it alone is // meaningless). const canSpawn = subagentDepth < MAX_SUBAGENT_DEPTH; const baseToolConfig = buildToolConfig(this.state); // Select tool entries by the session model's type (marked via forModel: vision // models use read_image, text-only models use describe_image; entries without // this marker are unaffected). const modelVision = modelEntry.vision !== false; let customTools = selectBuiltinToolsForModel(baseToolConfig.customTools, modelVision); if (!canSpawn) { customTools = customTools.filter( (d) => d.name !== SUBAGENT_NAME && d.name !== INPUT_SUBAGENT_NAME, ); } const toolConfig = { ...baseToolConfig, customTools }; // When the session model doesn't support images (vision=false): inject a vision // model service for describe_image (forModel: "text-only", selected by the filter // above) — images are described by the Project config's vision_model (a paired // reference), and the tool returns text. Even when unconfigured or invalid, it is // still injected (modelId=null); the tool then finishes with a failed explanation, // and images are never allowed into that session's history. let visionDescriber: VisionDescriberService | undefined; if (modelEntry.vision === false) { const visionRef = this.projectConfig.vision_model; const visionEntry = visionRef ? getModel(this.projectConfig, visionRef) : undefined; if (visionEntry && visionEntry.vision !== false) { visionDescriber = { // The model attribution in the tool output matches the request's source: both are the entry's upstream model_id. modelId: visionEntry.model_id, createLLM: () => new GenerativeModel({ modelId: visionEntry.model_id, ...(visionEntry.api_key !== undefined ? { apiKey: visionEntry.api_key } : {}), ...(visionEntry.base_url !== undefined ? { baseUrl: visionEntry.base_url } : {}), ...(visionEntry.client_type !== undefined ? { clientType: visionEntry.client_type } : {}), tools: [], thinkingLevel: "none", // The describing budget, tightened by the vision entry's own pinned cap when smaller. maxTokens: metaMaxTokens(2048, visionEntry.max_tokens), requestTimeoutMs: 60_000, }), }; } else { visionDescriber = { modelId: null }; } } // Environment binds the Workspace and tool config; tools are listed first so // GenerativeModel can be initialized. Vault environment variables are injected // into command subprocesses (shared by createSession and resumeSession; the // caller reads the current agent_state/.vault.toml); a child Agent loads **its // own** vault via createAgent rather than inheriting the parent's. const environment = new Environment({ workspaceDir, toolConfig, services: { subagentRunner, ...(visionDescriber ? { visionDescriber } : {}) }, ...(Object.keys(vault).length > 0 ? { vault } : {}), }); 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 // them unchanged. The model id sent to AgentHub is always the entry's upstream `model_id` // (client_type inference/passing follows it); session_meta, Trace, usage, pricing, and catalog // matching all use the (provider, model_id) pair as the primary key. // The tool_call_id uniqueness registry is shared with the new LLM rebuilt from llmConfig after // compaction: its uniqueness scope is the Session's whole render span, so same-named tool calls // after compaction don't collide with earlier tool cards' ids. const llmConfig: GenerativeModelConfig = { modelId: modelEntry.model_id, toolCallIds: new ToolCallIdAllocator(), ...(apiKey !== undefined ? { apiKey } : {}), ...(baseUrl !== undefined ? { baseUrl } : {}), ...(modelEntry.client_type !== undefined ? { clientType: modelEntry.client_type } : {}), tools, systemPrompt, ...(modelEntry.context_window !== undefined ? { contextWindow: modelEntry.context_window } : {}), ...(maxTokens !== undefined ? { maxTokens } : {}), ...(thinkingLevel !== undefined ? { thinkingLevel } : {}), ...(this.state.systemConfig.model?.timeoutMs !== undefined ? { requestTimeoutMs: this.state.systemConfig.model.timeoutMs } : {}), }; const llm = new GenerativeModel(llmConfig); const createLLM = (sessionTokens: TokenCounts): GenerativeModel => { const next = new GenerativeModel(llmConfig); // Carries over the Session's cumulative Token counts, so token_usage.session stays continuous across compaction. next.sessionTokens = sessionTokens; return next; }; // Bare LLM for one-off out-of-band requests (meta requests like generateTitle): // same Model/credentials, no tools, no system prompt, thinking disabled, a small // output cap, and an independent timeout. const createBareLLM = (): GenerativeModel => new GenerativeModel({ modelId: modelEntry.model_id, ...(apiKey !== undefined ? { apiKey } : {}), ...(baseUrl !== undefined ? { baseUrl } : {}), ...(modelEntry.client_type !== undefined ? { clientType: modelEntry.client_type } : {}), tools: [], thinkingLevel: "none", // The meta budget, tightened by the entry's pinned per-model cap when smaller. maxTokens: metaMaxTokens(300, modelEntry.max_tokens), requestTimeoutMs: 30_000, }); // Compaction config: defaults are filled in here; an unknown mode falls back to summarize (the default). const compactionConfig = this.state.systemConfig.compaction; const compaction: CompactionSettings = { maxContextLength: effectiveMaxContextLength( compactionConfig?.max_context_length ?? 128000, modelEntry.context_window, ), maxSessionTurns: compactionConfig?.max_session_turns ?? -1, mode: compactionConfig?.mode === "discard" ? "discard" : "summarize", prompt: compactionConfig?.prompt ?? DEFAULT_COMPACTION_PROMPT, }; return { environment, tools, llm, createLLM, createBareLLM, compaction }; } }