diff --git a/.changeset/context-tier.md b/.changeset/context-tier.md new file mode 100644 index 000000000..71f3f3294 --- /dev/null +++ b/.changeset/context-tier.md @@ -0,0 +1,15 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": minor +--- + +Thread a `contextTier` concept end-to-end through the agent spawning pipeline, mirroring `reasoningEffort`. + +Adds the ability to select a model's context window / context tier (Default vs Long-context / 1M) wherever a model and reasoning effort can already be chosen: + +- New `SquadContextTier = "default" | "long_context"` type and optional `SquadSessionConfig.contextTier`. +- Per-model `supportedContextTiers` / `defaultContextTier` in the model catalog so requests validate and clamp against what a model supports (e.g. Opus 4.8 โ†’ 264K default / 1M long context, inferred from billing token prices). +- `ModelConfig.defaultContextTier` and `agentContextTierOverrides` config-schema fields, with `readContextTier` / `writeContextTier` / `readAgentContextTierOverrides` / `writeAgentContextTierOverrides` / `resolveContextTier` / `clampContextTier` helpers mirroring the reasoning-effort resolvers (unsupported tier clamps to the model default; unknown treated as default; `auto` sentinel means "not set"). +- Charter `## Model` โ†’ `**Context Tier:**` support in the charter compiler. +- `contextTier` threaded through `spawn-backend.ts` (camelCase for task spawns, `context_tier` snake_case for session kickoff) and `fan-out.ts`. +- New `squad config context-tier [] [--agent ] [--clear]` CLI subcommand parallel to `squad config model`. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index e3e017f45..5dda80fdf 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -382,6 +382,33 @@ When the resolved reasoning effort is not `auto` or default, include it in the a Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.7-1m-internal ยท xhigh) โ€” deep architecture analysis`). +### Per-Agent Context Tier + +Context tier controls the size of the model's context window โ€” how much conversation, code, and instruction the model can hold at once. Larger tiers fit more context but cost more per token. This is SEPARATE from model selection and reasoning effort โ€” you can run the same model at different context tiers. + +Valid tiers: `default`, `long_context`. The value `auto` means "let the model decide" (platform default). A `long_context` request clamps to `default` on models that only support a single window. + +**Resolution โ€” check these layers in order (first match wins):** + +1. **Persistent Config:** `.squad/config.json` โ†’ `agentContextTierOverrides.{agentName}`, then `defaultContextTier` +2. **User directive:** User says "use long context" or "1M window" โ†’ apply to this spawn +3. **Charter preference:** Agent's `## Model` section โ†’ `**Context Tier:** long_context` +4. **Default:** Do not set a context tier (platform decides) + +**When user requests a larger window:** Use the SAME model with a different context tier โ€” do NOT switch to a different model variant. Context tier is a session parameter, not a model choice. + +- **When user says "always use long context" / "1M window by default":** Write `defaultContextTier` to `.squad/config.json`. Acknowledge: `โœ… Context tier saved: long_context โ€” all future sessions will use this until changed.` +- **When user says "use long context for {agent}":** Write to `agentContextTierOverrides.{agent}` in `.squad/config.json`. Acknowledge: `โœ… {Agent} will always use long context โ€” saved to config.` +- **When user says "clear context tier preference":** Remove context tier fields from `.squad/config.json`. Acknowledge: `โœ… Context tier preference cleared โ€” returning to automatic.` + +**Passing context tier to spawns:** + +When the resolved context tier is not `auto` or default, include it in the agent's charter-compiled spawn prompt or session config. The SDK threads it through to `SquadSessionConfig.contextTier` automatically, clamping to what the model supports. + +**Spawn output format โ€” show the model choice and tier:** + +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.8 ยท long context) โ€” 1M-token window for deep architecture analysis`). + ### Client Compatibility Detect the client surface once per session and adapt spawning behavior accordingly: CLI uses `task`/`read_agent`, VS Code uses `runSubagent`. diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index 4fe2b89cb..abbfcaaed 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -383,6 +383,33 @@ When the resolved reasoning effort is not `auto` or default, include it in the a Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.7-1m-internal ยท xhigh) โ€” deep architecture analysis`). +### Per-Agent Context Tier + +Context tier controls the size of the model's context window โ€” how much conversation, code, and instruction the model can hold at once. Larger tiers fit more context but cost more per token. This is SEPARATE from model selection and reasoning effort โ€” you can run the same model at different context tiers. + +Valid tiers: `default`, `long_context`. The value `auto` means "let the model decide" (platform default). A `long_context` request clamps to `default` on models that only support a single window. + +**Resolution โ€” check these layers in order (first match wins):** + +1. **Persistent Config:** `.squad/config.json` โ†’ `agentContextTierOverrides.{agentName}`, then `defaultContextTier` +2. **User directive:** User says "use long context" or "1M window" โ†’ apply to this spawn +3. **Charter preference:** Agent's `## Model` section โ†’ `**Context Tier:** long_context` +4. **Default:** Do not set a context tier (platform decides) + +**When user requests a larger window:** Use the SAME model with a different context tier โ€” do NOT switch to a different model variant. Context tier is a session parameter, not a model choice. + +- **When user says "always use long context" / "1M window by default":** Write `defaultContextTier` to `.squad/config.json`. Acknowledge: `โœ… Context tier saved: long_context โ€” all future sessions will use this until changed.` +- **When user says "use long context for {agent}":** Write to `agentContextTierOverrides.{agent}` in `.squad/config.json`. Acknowledge: `โœ… {Agent} will always use long context โ€” saved to config.` +- **When user says "clear context tier preference":** Remove context tier fields from `.squad/config.json`. Acknowledge: `โœ… Context tier preference cleared โ€” returning to automatic.` + +**Passing context tier to spawns:** + +When the resolved context tier is not `auto` or default, include it in the agent's charter-compiled spawn prompt or session config. The SDK threads it through to `SquadSessionConfig.contextTier` automatically, clamping to what the model supports. + +**Spawn output format โ€” show the model choice and tier:** + +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.8 ยท long context) โ€” 1M-token window for deep architecture analysis`). + ### Client Compatibility Detect the client surface once per session and adapt spawning behavior accordingly: CLI uses `task`/`read_agent`, VS Code uses `runSubagent`. diff --git a/packages/squad-cli/src/cli/commands/config.ts b/packages/squad-cli/src/cli/commands/config.ts index cfb474ea3..a910d5de9 100644 --- a/packages/squad-cli/src/cli/commands/config.ts +++ b/packages/squad-cli/src/cli/commands/config.ts @@ -7,6 +7,11 @@ * squad config model --agent โ€” pin model to a specific agent * squad config model --clear โ€” clear default model override * squad config model --clear --agent โ€” clear a specific agent's override + * squad config context-tier โ€” show current context tier configuration + * squad config context-tier โ€” set default context tier for all agents + * squad config context-tier --agent โ€” pin context tier to a specific agent + * squad config context-tier --clear โ€” clear default context tier override + * squad config context-tier --clear --agent โ€” clear a specific agent's override */ import { join } from 'node:path'; @@ -16,6 +21,11 @@ import { writeModelPreference, readAgentModelOverrides, writeAgentModelOverrides, + readContextTier, + writeContextTier, + readAgentContextTierOverrides, + writeAgentContextTierOverrides, + VALID_CONTEXT_TIERS, MODEL_CATALOG, } from '@bradygaster/squad-sdk/config'; import { fatal } from '../core/errors.js'; @@ -61,6 +71,14 @@ function showAvailableModels(): void { console.log(); } +function isValidTier(name: string): boolean { + return (VALID_CONTEXT_TIERS as readonly string[]).includes(name); +} + +function showAvailableTiers(): void { + console.log(`\n Available context tiers: ${DIM}${VALID_CONTEXT_TIERS.join(', ')}${RESET}\n`); +} + function parseFlags(args: string[]): { clear: boolean; agent: string | null; positional: string[] } { let clear = false; let agent: string | null = null; @@ -153,6 +171,77 @@ async function runModelSubcommand(squadDir: string, subArgs: string[]): Promise< console.log(); } +async function runContextTierSubcommand(squadDir: string, subArgs: string[]): Promise { + const { clear, agent, positional } = parseFlags(subArgs); + const tierArg = positional[0] ?? null; + + // --- Clear --- + if (clear) { + if (agent) { + const agents = listAgents(squadDir); + if (agents.length > 0 && !agents.includes(agent)) { + fatal( + `Unknown agent "${agent}".\n` + + ` Known agents: ${agents.join(', ')}`, + ); + } + const overrides = readAgentContextTierOverrides(squadDir); + delete overrides[agent]; + writeAgentContextTierOverrides(squadDir, overrides); + console.log(`${GREEN}โœ“${RESET} Context tier override for ${BOLD}${agent}${RESET} cleared.`); + } else { + writeContextTier(squadDir, null); + console.log(`${GREEN}โœ“${RESET} Default context tier cleared (reverted to model default).`); + } + return; + } + + // --- Set context tier --- + if (tierArg) { + if (!isValidTier(tierArg)) { + console.error(`${RED}โœ—${RESET} Unknown context tier: ${BOLD}${tierArg}${RESET}`); + showAvailableTiers(); + process.exit(1); + } + + if (agent) { + const agents = listAgents(squadDir); + if (agents.length > 0 && !agents.includes(agent)) { + fatal( + `Unknown agent "${agent}".\n` + + ` Known agents: ${agents.join(', ')}`, + ); + } + const overrides = readAgentContextTierOverrides(squadDir); + overrides[agent] = tierArg; + writeAgentContextTierOverrides(squadDir, overrides); + console.log(`${GREEN}โœ“${RESET} Context tier for ${BOLD}${agent}${RESET} set to ${BOLD}${tierArg}${RESET}`); + } else { + writeContextTier(squadDir, tierArg); + console.log(`${GREEN}โœ“${RESET} Default context tier set to ${BOLD}${tierArg}${RESET}`); + } + return; + } + + // --- Show current config --- + const defaultTier = readContextTier(squadDir); + const overrides = readAgentContextTierOverrides(squadDir); + const overrideEntries = Object.entries(overrides); + + console.log(`\n${BOLD}Context tier configuration:${RESET}`); + console.log(` Default context tier: ${defaultTier ? BOLD + defaultTier + RESET : `${DIM}(model default)${RESET}`}`); + + if (overrideEntries.length > 0) { + console.log(`\n Agent overrides:`); + for (const [name, tier] of overrideEntries) { + console.log(` ${name} ${DIM}โ†’${RESET} ${tier}`); + } + } else { + console.log(`\n ${DIM}No agent overrides configured.${RESET}`); + } + showAvailableTiers(); +} + export async function runConfig(cwd: string, subArgs: string[]): Promise { const squadDir = resolveSquadDir(cwd); if (!squadDir) { @@ -167,6 +256,11 @@ export async function runConfig(cwd: string, subArgs: string[]): Promise { return; } + if (sub === 'context-tier') { + await runContextTierSubcommand(squadDir, subArgs.slice(1)); + return; + } + // No subcommand or unknown โ€” show usage console.log(`\n${BOLD}squad config${RESET} โ€” manage squad configuration\n`); console.log(` ${BOLD}squad config model${RESET} โ€” show current model config`); @@ -174,4 +268,9 @@ export async function runConfig(cwd: string, subArgs: string[]): Promise { console.log(` ${BOLD}squad config model --agent ${RESET} โ€” pin model to agent`); console.log(` ${BOLD}squad config model --clear${RESET} โ€” clear default model`); console.log(` ${BOLD}squad config model --clear --agent ${RESET} โ€” clear agent override\n`); + console.log(` ${BOLD}squad config context-tier${RESET} โ€” show current context-tier config`); + console.log(` ${BOLD}squad config context-tier ${RESET} โ€” set default context tier`); + console.log(` ${BOLD}squad config context-tier --agent ${RESET} โ€” pin context tier to agent`); + console.log(` ${BOLD}squad config context-tier --clear${RESET} โ€” clear default context tier`); + console.log(` ${BOLD}squad config context-tier --clear --agent ${RESET} โ€” clear agent override\n`); } diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 4fe2b89cb..abbfcaaed 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -383,6 +383,33 @@ When the resolved reasoning effort is not `auto` or default, include it in the a Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.7-1m-internal ยท xhigh) โ€” deep architecture analysis`). +### Per-Agent Context Tier + +Context tier controls the size of the model's context window โ€” how much conversation, code, and instruction the model can hold at once. Larger tiers fit more context but cost more per token. This is SEPARATE from model selection and reasoning effort โ€” you can run the same model at different context tiers. + +Valid tiers: `default`, `long_context`. The value `auto` means "let the model decide" (platform default). A `long_context` request clamps to `default` on models that only support a single window. + +**Resolution โ€” check these layers in order (first match wins):** + +1. **Persistent Config:** `.squad/config.json` โ†’ `agentContextTierOverrides.{agentName}`, then `defaultContextTier` +2. **User directive:** User says "use long context" or "1M window" โ†’ apply to this spawn +3. **Charter preference:** Agent's `## Model` section โ†’ `**Context Tier:** long_context` +4. **Default:** Do not set a context tier (platform decides) + +**When user requests a larger window:** Use the SAME model with a different context tier โ€” do NOT switch to a different model variant. Context tier is a session parameter, not a model choice. + +- **When user says "always use long context" / "1M window by default":** Write `defaultContextTier` to `.squad/config.json`. Acknowledge: `โœ… Context tier saved: long_context โ€” all future sessions will use this until changed.` +- **When user says "use long context for {agent}":** Write to `agentContextTierOverrides.{agent}` in `.squad/config.json`. Acknowledge: `โœ… {Agent} will always use long context โ€” saved to config.` +- **When user says "clear context tier preference":** Remove context tier fields from `.squad/config.json`. Acknowledge: `โœ… Context tier preference cleared โ€” returning to automatic.` + +**Passing context tier to spawns:** + +When the resolved context tier is not `auto` or default, include it in the agent's charter-compiled spawn prompt or session config. The SDK threads it through to `SquadSessionConfig.contextTier` automatically, clamping to what the model supports. + +**Spawn output format โ€” show the model choice and tier:** + +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.8 ยท long context) โ€” 1M-token window for deep architecture analysis`). + ### Client Compatibility Detect the client surface once per session and adapt spawning behavior accordingly: CLI uses `task`/`read_agent`, VS Code uses `runSubagent`. diff --git a/packages/squad-sdk/src/adapter/client.ts b/packages/squad-sdk/src/adapter/client.ts index e56c8f353..ccbfa5be8 100644 --- a/packages/squad-sdk/src/adapter/client.ts +++ b/packages/squad-sdk/src/adapter/client.ts @@ -764,6 +764,14 @@ export class SquadClient { billing: m.billing, supportedReasoningEfforts: m.supportedReasoningEfforts, defaultReasoningEffort: m.defaultReasoningEffort, + // Infer context-tier support from billing: models that publish a + // long-context token-price schedule support the 1M window. The + // runtime does not expose supported tiers directly, so presence of + // `billing.tokenPrices.longContext` is the authoritative signal. + supportedContextTiers: m.billing?.tokenPrices?.longContext + ? ["default", "long_context"] + : ["default"], + defaultContextTier: "default", })); } catch (error) { if (this.shouldAttemptReconnect(error)) { diff --git a/packages/squad-sdk/src/adapter/types.ts b/packages/squad-sdk/src/adapter/types.ts index 89a944ef4..7a2b14657 100644 --- a/packages/squad-sdk/src/adapter/types.ts +++ b/packages/squad-sdk/src/adapter/types.ts @@ -42,6 +42,15 @@ export interface SquadSessionConfig { */ reasoningEffort?: SquadReasoningEffort; + /** + * Context tier (context-window size) for models that support it. + * Selects between the default context window and an extended + * long-context window (e.g. Opus 4.8: 264K default vs 1M long context). + * Only valid for models where capabilities support multiple context tiers. + * @example "long_context" + */ + contextTier?: SquadContextTier; + /** * Override the default configuration directory location. * When specified, the session will use this directory for config and state. @@ -759,6 +768,17 @@ export interface SquadInfiniteSessionConfig { */ export type SquadReasoningEffort = "low" | "medium" | "high" | "xhigh"; +/** + * Valid context tiers (context-window sizes) for models that support them. + * + * `"default"` selects the model's standard context window; `"long_context"` + * selects an extended window (e.g. Opus 4.8: 264K default vs 1M long context). + * Canonical context-tier union for the SDK โ€” import this type instead of + * re-declaring the union inline. The runtime list lives in config/models.ts as + * `VALID_CONTEXT_TIERS` (kept in sync via `satisfies`). + */ +export type SquadContextTier = "default" | "long_context"; + // ============================================================================ // Session Interface // ============================================================================ @@ -951,6 +971,14 @@ export interface SquadModelCapabilities { supports: { vision: boolean; reasoningEffort: boolean; + /** + * Whether the model exposes a selectable context tier (extended context + * window). Optional because the underlying runtime does not model this as + * a capability flag; Squad infers per-model support from the billing + * signal and surfaces it via `supportedContextTiers` / `defaultContextTier` + * on {@link SquadModelInfo} instead. + */ + contextTier?: boolean; }; limits: { max_prompt_tokens?: number; @@ -989,4 +1017,15 @@ export interface SquadModelInfo { billing?: SquadModelBilling; supportedReasoningEfforts?: SquadReasoningEffort[]; defaultReasoningEffort?: SquadReasoningEffort; + /** + * Context tiers (context-window sizes) this model supports. When a model + * exposes an extended/long context window this is `["default", "long_context"]`; + * otherwise it is `["default"]`. Inferred from the runtime billing signal. + */ + supportedContextTiers?: SquadContextTier[]; + /** + * The context tier used when none is explicitly requested. Almost always + * `"default"`; models without an extended window only report `"default"`. + */ + defaultContextTier?: SquadContextTier; } diff --git a/packages/squad-sdk/src/agents/charter-compiler.ts b/packages/squad-sdk/src/agents/charter-compiler.ts index 48f352898..ba1ea1ef9 100644 --- a/packages/squad-sdk/src/agents/charter-compiler.ts +++ b/packages/squad-sdk/src/agents/charter-compiler.ts @@ -9,11 +9,14 @@ import { SquadCustomAgentConfig } from '../adapter/types.js'; import { ConfigurationError } from '../adapter/errors.js'; import { normalizeEol } from '../utils/normalize-eol.js'; -import { VALID_REASONING_EFFORTS } from '../config/models.js'; +import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS } from '../config/models.js'; /** Set form for fast lookup. */ const VALID_EFFORTS = new Set(VALID_REASONING_EFFORTS); +/** Set form for fast lookup. */ +const VALID_TIERS = new Set(VALID_CONTEXT_TIERS); + /** * Options for compiling a charter. */ @@ -49,6 +52,8 @@ export interface CharterConfigOverrides { model?: string; /** Override or set reasoning effort level */ reasoningEffort?: string; + /** Override or set context tier (context window size) */ + contextTier?: string; /** Override or set tools list */ tools?: string[]; /** Override or set status */ @@ -80,6 +85,8 @@ export interface ParsedCharter { modelFallback?: string; /** Reasoning effort preference from ## Model section */ reasoningEffort?: string; + /** Context tier preference from ## Model section */ + contextTier?: string; /** Collaboration section content */ collaboration?: string; /** Full charter content */ @@ -94,6 +101,8 @@ export interface CompiledCharter extends SquadCustomAgentConfig { resolvedModel?: string; /** Resolved reasoning effort (from config override or charter preference) */ resolvedReasoningEffort?: string; + /** Resolved context tier (from config override or charter preference) */ + resolvedContextTier?: string; /** Resolved tools list (from config override or charter) */ resolvedTools?: string[]; /** Parsed charter data */ @@ -177,6 +186,13 @@ export function compileCharterFull(options: CharterCompileOptions): CompiledChar const validConfigEffort = configEffort && configEffort !== 'auto' && VALID_EFFORTS.has(configEffort) ? configEffort : undefined; const resolvedReasoningEffort = validConfigEffort || charterEffort; + // Resolve context tier: config override > charter preference + // Normalize: "auto" and invalid values resolve to undefined + const configTier = configOverrides?.contextTier?.toLowerCase(); + const charterTier = parsed.contextTier; // already validated during parsing + const validConfigTier = configTier && configTier !== 'auto' && VALID_TIERS.has(configTier) ? configTier : undefined; + const resolvedContextTier = validConfigTier || charterTier; + // Resolve tools: config override > charter-extracted tools const resolvedTools = configOverrides?.tools; @@ -189,6 +205,7 @@ export function compileCharterFull(options: CharterCompileOptions): CompiledChar tools: resolvedTools ?? null, resolvedModel, resolvedReasoningEffort, + resolvedContextTier, resolvedTools, parsed, }; @@ -290,6 +307,20 @@ export function parseCharterMarkdown(content: string): ParsedCharter { ); } } + const tierMatch = modelContent.match(/\*\*Context Tier:\*\*\s*(.+)/i); + if (tierMatch) { + const raw = tierMatch[1]!.trim().toLowerCase(); + // Normalize: "auto" โ†’ undefined, invalid values โ†’ undefined + if (raw !== 'auto' && VALID_TIERS.has(raw)) { + result.contextTier = raw; + } else if (raw !== 'auto') { + // Surface invalid charter input to the author instead of dropping it silently. + console.warn( + `[squad] charter parse: ignoring invalid context tier "${raw}" ` + + `(expected ${VALID_CONTEXT_TIERS.join(', ')}, or auto)`, + ); + } + } } // Extract ## Collaboration section diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index b84771f8a..bc9c901f4 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -125,6 +125,9 @@ export interface AgentCharter { /** Reasoning effort preference from charter */ reasoningEffort?: string; + + /** Context tier preference from charter */ + contextTier?: string; } export type AgentLifecycleState = 'pending' | 'spawning' | 'active' | 'idle' | 'error' | 'destroyed'; @@ -176,6 +179,7 @@ export class CharterCompiler { prompt: content, modelPreference: parsed.modelPreference, reasoningEffort: parsed.reasoningEffort, + contextTier: parsed.contextTier, }; } @@ -205,6 +209,7 @@ export class CharterCompiler { prompt: content, modelPreference: parsed.modelPreference, reasoningEffort: parsed.reasoningEffort, + contextTier: parsed.contextTier, }; } diff --git a/packages/squad-sdk/src/agents/lifecycle.ts b/packages/squad-sdk/src/agents/lifecycle.ts index 4ffbf8839..23d2c352c 100644 --- a/packages/squad-sdk/src/agents/lifecycle.ts +++ b/packages/squad-sdk/src/agents/lifecycle.ts @@ -8,10 +8,10 @@ */ import { SquadClientWithPool } from '../client/index.js'; -import type { SquadSession, SquadSessionConfig, SquadReasoningEffort } from '../adapter/types.js'; +import type { SquadSession, SquadSessionConfig, SquadReasoningEffort, SquadContextTier } from '../adapter/types.js'; import { compileCharterFull, type CharterCompileOptions } from './charter-compiler.js'; import { resolveModel, type ModelResolutionOptions, type TaskType } from './model-selector.js'; -import { VALID_REASONING_EFFORTS } from '../config/models.js'; +import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS } from '../config/models.js'; import { ConfigurationError, SessionLifecycleError } from '../adapter/errors.js'; import * as path from 'path'; import { FSStorageProvider } from '../storage/fs-storage-provider.js'; @@ -76,6 +76,9 @@ export interface SpawnAgentOptions { /** User-specified reasoning effort override */ reasoningEffortOverride?: SquadReasoningEffort; + + /** User-specified context tier override */ + contextTierOverride?: SquadContextTier; /** Team context content (team.md) */ teamContext?: string; @@ -155,6 +158,7 @@ export class AgentLifecycleManager { taskType = 'code', modelOverride, reasoningEffortOverride, + contextTierOverride, teamContext, routingRules, decisions, @@ -218,12 +222,21 @@ export class AgentLifecycleManager { const validEffort = rawEffort && rawEffort !== 'auto' && (VALID_REASONING_EFFORTS as readonly string[]).includes(rawEffort) ? rawEffort as SquadReasoningEffort : undefined; + // Use compiled charter's resolved context tier (already validated/normalized), + // with spawn-time override taking precedence. Validate before passing to session. + const rawTier = contextTierOverride + || agentConfig.resolvedContextTier + || undefined; + const validTier = rawTier && rawTier !== 'auto' && (VALID_CONTEXT_TIERS as readonly string[]).includes(rawTier) + ? rawTier as SquadContextTier + : undefined; const sessionConfig: SquadSessionConfig = { model: resolvedModel.model, systemMessage: { content: agentConfig.prompt, }, ...(validEffort ? { reasoningEffort: validEffort } : {}), + ...(validTier ? { contextTier: validTier } : {}), }; const session = await this.client.createSession(sessionConfig); diff --git a/packages/squad-sdk/src/builders/index.ts b/packages/squad-sdk/src/builders/index.ts index 8bff79ad8..d1a4b5680 100644 --- a/packages/squad-sdk/src/builders/index.ts +++ b/packages/squad-sdk/src/builders/index.ts @@ -24,7 +24,7 @@ import type { SquadSDKConfig, } from './types.js'; -import { VALID_REASONING_EFFORTS } from '../config/models.js'; +import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS } from '../config/models.js'; // Re-export every type so consumers can `import { defineTeam, TeamDefinition } from './builders'` export type { @@ -210,6 +210,9 @@ export function defineAgent(config: AgentDefinition): AgentDefinition { if (config.reasoningEffort !== undefined) { assertStringUnion(config.reasoningEffort, VALID_REASONING_EFFORTS, 'reasoningEffort', 'defineAgent'); } + if (config.contextTier !== undefined) { + assertStringUnion(config.contextTier, VALID_CONTEXT_TIERS, 'contextTier', 'defineAgent'); + } if (config.budget !== undefined) { defineBudget(config.budget); } @@ -475,6 +478,9 @@ export function defineDefaults(config: DefaultsDefinition): DefaultsDefinition { if (config.reasoningEffort !== undefined) { assertStringUnion(config.reasoningEffort, VALID_REASONING_EFFORTS, 'reasoningEffort', 'defineDefaults'); } + if (config.contextTier !== undefined) { + assertStringUnion(config.contextTier, VALID_CONTEXT_TIERS, 'contextTier', 'defineDefaults'); + } if (config.budget !== undefined) { defineBudget(config.budget); } diff --git a/packages/squad-sdk/src/builders/types.ts b/packages/squad-sdk/src/builders/types.ts index db5f183eb..c4a4f9f8c 100644 --- a/packages/squad-sdk/src/builders/types.ts +++ b/packages/squad-sdk/src/builders/types.ts @@ -9,7 +9,7 @@ * @module builders/types */ -import type { SquadReasoningEffort } from '../adapter/types.js'; +import type { SquadReasoningEffort, SquadContextTier } from '../adapter/types.js'; // --------------------------------------------------------------------------- // Shared primitives @@ -71,6 +71,9 @@ export interface DefaultsDefinition { /** Default reasoning effort for agents that don't specify one. */ readonly reasoningEffort?: SquadReasoningEffort; + /** Default context tier for agents that don't specify one. */ + readonly contextTier?: SquadContextTier; + /** Default budget limits applied to agents that don't specify one. */ readonly budget?: BudgetDefinition; } @@ -125,6 +128,9 @@ export interface AgentDefinition { /** Reasoning effort level for models that support it. */ readonly reasoningEffort?: SquadReasoningEffort; + /** Context tier (context window size) for models that support it. */ + readonly contextTier?: SquadContextTier; + /** Optional token budget for this agent. */ readonly budget?: BudgetDefinition; diff --git a/packages/squad-sdk/src/config/models.ts b/packages/squad-sdk/src/config/models.ts index 7ac6f1ab4..1b8ac22fe 100644 --- a/packages/squad-sdk/src/config/models.ts +++ b/packages/squad-sdk/src/config/models.ts @@ -11,7 +11,7 @@ import { join } from 'path'; import type { StorageProvider } from '../storage/index.js'; import { FSStorageProvider } from '../storage/index.js'; import type { ModelId, ModelTier } from '../runtime/config.js'; -import type { SquadReasoningEffort } from '../adapter/types.js'; +import type { SquadReasoningEffort, SquadContextTier } from '../adapter/types.js'; /** * Per-token pricing in USD. @@ -551,6 +551,8 @@ export interface ModelPreferenceConfig { economyMode?: boolean; defaultReasoningEffort?: string; agentReasoningEffortOverrides?: Record; + defaultContextTier?: string; + agentContextTierOverrides?: Record; } /** @@ -1064,6 +1066,307 @@ export function resolveReasoningEffort(options: { return resolved; } +/** + * Valid context tiers. + * "auto" is a permitted stored sentinel that resolvers treat as "not set". + * Canonical runtime list โ€” import this instead of duplicating. The `satisfies` + * clause keeps it in lock-step with the canonical {@link SquadContextTier} type. + * + * Unlike reasoning effort there is no ranked scale: this is a two-value enum + * ("default" = the model's standard window, "long_context" = its extended/1M + * window). Clamping is therefore membership-based, not rank-based. + */ +export const VALID_CONTEXT_TIERS = ['default', 'long_context'] as const satisfies readonly SquadContextTier[]; +/** Canonical context-tier union (alias of {@link SquadContextTier}). */ +export type ValidContextTier = SquadContextTier; +const VALID_CONTEXT_TIERS_WITH_AUTO: readonly string[] = [...VALID_CONTEXT_TIERS, 'auto']; +/** String array form for runtime `.includes()` checks with arbitrary strings. */ +const VALID_CONTEXT_TIERS_SET: readonly string[] = VALID_CONTEXT_TIERS; + +/** + * Clamp a requested context tier to what the model actually supports. + * + * Semantics deliberately differ from {@link clampReasoningEffort}: context tier + * is a two-value enum, not a ranked scale, so there is nothing to "clamp down" + * to a nearest lower level. The rules are: + * - Nothing requested (undefined/null/empty) โ†’ undefined (let the runtime decide). + * - Unknown / invalid tier string โ†’ the model default (or "default"). This is + * the "unknown treated as default" rule from issue #1446. + * - Model capabilities unknown (no supportedTiers) โ†’ trust the valid request. + * - Requested tier supported โ†’ return it unchanged. + * - Requested tier unsupported (e.g. "long_context" on a model without a + * long-context window) โ†’ clamp to the model's default tier, or "default". + * + * @param requested - The context tier the user/charter requested + * @param supportedTiers - The model's supportedContextTiers from listModels() + * @param modelDefault - The model's defaultContextTier from listModels() + * @returns The clamped tier, or undefined if nothing was requested + */ +export function clampContextTier( + requested: string | undefined, + supportedTiers: string[] | undefined, + modelDefault?: string, +): string | undefined { + if (!requested) return undefined; + + const fallback = modelDefault && VALID_CONTEXT_TIERS_SET.includes(modelDefault) + ? modelDefault + : 'default'; + + // Unknown / invalid tier string โ†’ treat as default. + if (!VALID_CONTEXT_TIERS_SET.includes(requested)) return fallback; + + // Model capabilities unknown โ†’ trust the (valid) request; the runtime validates. + if (!supportedTiers || supportedTiers.length === 0) return requested; + + // Supported โ†’ use it; unsupported โ†’ clamp to the model's default (or "default"). + return supportedTiers.includes(requested) ? requested : fallback; +} + +/** + * Reads the persistent context tier preference from `.squad/config.json`. + * + * @param squadDir - Path to the `.squad/` directory + * @returns The defaultContextTier string if set, or null + */ +export function readContextTier(squadDir: string, storage: StorageProvider = new FSStorageProvider()): string | null { + const configPath = join(squadDir, 'config.json'); + if (!storage.existsSync(configPath)) { + return null; + } + try { + const raw = storage.readSync(configPath); + if (raw === undefined) return null; + const parsed = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) && + typeof parsed.defaultContextTier === 'string' && + parsed.defaultContextTier.length > 0 && + VALID_CONTEXT_TIERS_WITH_AUTO.includes(parsed.defaultContextTier) + ) { + return parsed.defaultContextTier; + } + return null; + } catch { + return null; + } +} + +/** + * Reads per-agent context tier overrides from `.squad/config.json`. + * + * @param squadDir - Path to the `.squad/` directory + * @returns Record of agent name โ†’ context tier, or empty object + */ +export function readAgentContextTierOverrides(squadDir: string, storage: StorageProvider = new FSStorageProvider()): Record { + const configPath = join(squadDir, 'config.json'); + if (!storage.existsSync(configPath)) { + return {}; + } + try { + const raw = storage.readSync(configPath); + if (raw === undefined) return {}; + const parsed = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + typeof parsed.agentContextTierOverrides === 'object' && + parsed.agentContextTierOverrides !== null + ) { + const result: Record = {}; + for (const [key, value] of Object.entries(parsed.agentContextTierOverrides)) { + if (typeof value === 'string' && VALID_CONTEXT_TIERS_SET.includes(value)) { + result[key] = value; + } + } + return result; + } + return {}; + } catch { + return {}; + } +} + +/** + * Writes a persistent context tier preference to `.squad/config.json`. + * Merges with existing config โ€” does not overwrite other fields. + * + * @param squadDir - Path to the `.squad/` directory + * @param tier - Context tier to persist, or null to clear + */ +export function writeContextTier(squadDir: string, tier: string | null, storage: StorageProvider = new FSStorageProvider()): void { + const configPath = join(squadDir, 'config.json'); + let config: Record = {}; + if (storage.existsSync(configPath)) { + try { + const raw = storage.readSync(configPath); + const parsed = raw !== undefined ? JSON.parse(raw) : null; + config = (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) + ? parsed as Record + : { version: 1 }; + } catch { + config = { version: 1 }; + } + } else { + config = { version: 1 }; + } + + if (tier === null) { + delete config.defaultContextTier; + } else if (VALID_CONTEXT_TIERS_WITH_AUTO.includes(tier)) { + config.defaultContextTier = tier; + } else { + // Invalid value: warn and leave any existing preference untouched rather + // than silently clearing it (a typo shouldn't wipe a valid setting). + // Pass null explicitly to clear. + console.warn( + `[squad] writeContextTier: ignoring invalid context tier "${tier}" ` + + `(expected ${VALID_CONTEXT_TIERS.join(', ')}, auto, or null to clear); ` + + `existing preference left unchanged.`, + ); + return; + } + + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); +} + +/** + * Writes per-agent context tier overrides to `.squad/config.json`. + * Merges with existing config โ€” does not overwrite other fields. + * + * @param squadDir - Path to the `.squad/` directory + * @param overrides - Record of agent name โ†’ context tier, or null to clear + */ +export function writeAgentContextTierOverrides( + squadDir: string, + overrides: Record | null, + storage: StorageProvider = new FSStorageProvider() +): void { + const configPath = join(squadDir, 'config.json'); + let config: Record = {}; + if (storage.existsSync(configPath)) { + try { + const raw = storage.readSync(configPath); + const parsed = raw !== undefined ? JSON.parse(raw) : null; + config = (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) + ? parsed as Record + : { version: 1 }; + } catch { + config = { version: 1 }; + } + } else { + config = { version: 1 }; + } + + if (overrides === null || Object.keys(overrides).length === 0) { + delete config.agentContextTierOverrides; + } else { + // Filter out invalid tier values, warning about any dropped so a typo + // isn't silently discarded. + const validated: Record = {}; + const dropped: string[] = []; + for (const [agent, tier] of Object.entries(overrides)) { + if (VALID_CONTEXT_TIERS_WITH_AUTO.includes(tier)) { + validated[agent] = tier; + } else { + dropped.push(`${agent}="${tier}"`); + } + } + if (dropped.length > 0) { + console.warn( + `[squad] writeAgentContextTierOverrides: ignoring invalid context tier ` + + `value(s) ${dropped.join(', ')} (expected ${VALID_CONTEXT_TIERS.join(', ')}, or auto).`, + ); + } + if (Object.keys(validated).length > 0) { + config.agentContextTierOverrides = validated; + } else { + delete config.agentContextTierOverrides; + } + } + + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); +} + +/** + * Resolves the effective context tier for an agent spawn. + * Uses a layered priority system matching the reasoning-effort resolution pattern: + * Layer 0a: Per-agent persistent override (.squad/config.json agentContextTierOverrides) + * Layer 0b: Global persistent config (.squad/config.json defaultContextTier) + * Layer 1: Spawn-time override (caller-provided) + * Layer 2: Charter preference (agent's ## Model โ†’ **Context Tier:** field) + * Layer 3: Default (undefined โ€” let SDK/runtime decide) + * + * The value "auto" at any layer is treated as "not set" and falls through. + * + * When `supportedContextTiers` is provided (from the model's capabilities via + * listModels()), the resolved tier is clamped to what the model supports. This + * prevents errors when a user requests "long_context" on a model that only + * exposes a default window. + * + * @param options - Resolution inputs + * @returns Resolved context tier string, or undefined if unset + */ +export function resolveContextTier(options: { + agentName?: string; + squadDir?: string; + spawnOverride?: string | null; + charterPreference?: string | null; + /** Model's supportedContextTiers from listModels(). When provided, clamps the result. */ + supportedContextTiers?: string[]; + /** Model's defaultContextTier from listModels(). Used as the clamp fallback. */ + defaultContextTier?: string; + storage?: StorageProvider; +}): string | undefined { + const { agentName, squadDir, spawnOverride, charterPreference } = options; + const storage = options.storage ?? new FSStorageProvider(); + + let resolved: string | undefined; + + // Helper: only accept valid tier values (reject invalid strings and "auto") + const isValid = (v: string | null | undefined): v is string => + typeof v === 'string' && v !== 'auto' && VALID_CONTEXT_TIERS_SET.includes(v); + + // Layer 0a: Per-agent persistent override + if (!resolved && squadDir && agentName) { + const agentOverrides = readAgentContextTierOverrides(squadDir, storage); + const agentTier = agentOverrides[agentName]; + if (isValid(agentTier)) { + resolved = agentTier; + } + } + + // Layer 0b: Global persistent config + if (!resolved && squadDir) { + const persistedTier = readContextTier(squadDir, storage); + if (isValid(persistedTier)) { + resolved = persistedTier; + } + } + + // Layer 1: Spawn-time override + if (!resolved && isValid(spawnOverride)) { + resolved = spawnOverride; + } + + // Layer 2: Charter preference + if (!resolved && isValid(charterPreference)) { + resolved = charterPreference; + } + + // Layer 3: Default โ€” undefined (let SDK/runtime decide) + if (!resolved) return undefined; + + // Clamp to model capabilities if available + if (options.supportedContextTiers) { + return clampContextTier(resolved, options.supportedContextTiers, options.defaultContextTier); + } + + return resolved; +} + /** * Resolves the effective model for an agent spawn using the 5-layer hierarchy: * Layer 0: Persistent config (.squad/config.json defaultModel) diff --git a/packages/squad-sdk/src/config/schema.ts b/packages/squad-sdk/src/config/schema.ts index 24c47dab6..60295ccbb 100644 --- a/packages/squad-sdk/src/config/schema.ts +++ b/packages/squad-sdk/src/config/schema.ts @@ -31,6 +31,7 @@ export interface AgentConfig { charter?: string; model?: string; reasoningEffort?: string; + contextTier?: string; tools?: string[]; status?: 'active' | 'inactive' | 'retired'; } @@ -52,9 +53,11 @@ export interface ModelConfig { default: string; defaultTier: 'premium' | 'standard' | 'fast'; defaultReasoningEffort?: string; + defaultContextTier?: string; tiers: Record; agentOverrides?: Record; agentReasoningEffortOverrides?: Record; + agentContextTierOverrides?: Record; taskTypeMapping?: Record; } diff --git a/packages/squad-sdk/src/coordinator/fan-out.ts b/packages/squad-sdk/src/coordinator/fan-out.ts index b44eef3d0..5f3b53deb 100644 --- a/packages/squad-sdk/src/coordinator/fan-out.ts +++ b/packages/squad-sdk/src/coordinator/fan-out.ts @@ -11,7 +11,7 @@ import type { AgentCharter } from '../agents/index.js'; import type { EventBus } from '../client/event-bus.js'; import type { SessionPool } from '../client/session-pool.js'; -import { VALID_REASONING_EFFORTS } from '../config/models.js'; +import { VALID_CONTEXT_TIERS, VALID_REASONING_EFFORTS } from '../config/models.js'; import type { CreateSessionFn, SpawnBackend, SpawnHandle, SpawnRequest } from './spawn-backend.js'; // --- Spawn Configuration --- @@ -29,6 +29,8 @@ export interface AgentSpawnConfig { modelOverride?: string; /** Reasoning effort override */ reasoningEffortOverride?: string; + /** Context tier override */ + contextTierOverride?: string; } // --- Spawn Result --- @@ -57,6 +59,8 @@ export interface FanOutDependencies { resolveModel: (charter: AgentCharter, override?: string) => Promise; /** Reasoning effort resolution function (optional for backwards compatibility) */ resolveReasoningEffort?: (charter: AgentCharter, override?: string) => Promise; + /** Context tier resolution function (optional for backwards compatibility) */ + resolveContextTier?: (charter: AgentCharter, override?: string) => Promise; /** Session creation function */ createSession: CreateSessionFn; /** Session pool for tracking */ @@ -143,6 +147,16 @@ async function spawnSingle( ? rawEffort : undefined; + // Step 2c: Resolve context tier + const rawTier = deps.resolveContextTier + ? await deps.resolveContextTier(charter, config.contextTierOverride) + : config.contextTierOverride || charter.contextTier || undefined; + // Validate: only pass through recognized tier values + const validTiers = VALID_CONTEXT_TIERS as readonly string[]; + const contextTier = rawTier && rawTier !== 'auto' && validTiers.includes(rawTier) + ? rawTier + : undefined; + const initialPrompt = buildInitialPrompt(config); // Step 3: Create session @@ -157,6 +171,7 @@ async function spawnSingle( name: config.agentName, model, ...(reasoningEffort ? { reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), background: true, }; @@ -182,10 +197,10 @@ async function spawnSingle( }, timestamp: new Date(), }); - sessionId = await spawnViaCreateSession(deps, config, model, reasoningEffort, initialPrompt); + sessionId = await spawnViaCreateSession(deps, config, model, reasoningEffort, contextTier, initialPrompt); } } else { - sessionId = await spawnViaCreateSession(deps, config, model, reasoningEffort, initialPrompt); + sessionId = await spawnViaCreateSession(deps, config, model, reasoningEffort, contextTier, initialPrompt); } // Step 4: Register in session pool @@ -246,12 +261,14 @@ async function spawnViaCreateSession( config: AgentSpawnConfig, model: string, reasoningEffort: string | undefined, + contextTier: string | undefined, initialPrompt: string, ): Promise { const session = await deps.createSession({ model, clientName: `squad-agent-${config.agentName}`, ...(reasoningEffort ? { reasoningEffort } : {}), + ...(contextTier ? { contextTier } : {}), }); await session.sendMessage({ diff --git a/packages/squad-sdk/src/coordinator/spawn-backend.ts b/packages/squad-sdk/src/coordinator/spawn-backend.ts index 53d7fee0a..d4154b4c1 100644 --- a/packages/squad-sdk/src/coordinator/spawn-backend.ts +++ b/packages/squad-sdk/src/coordinator/spawn-backend.ts @@ -28,6 +28,8 @@ export interface SpawnRequest { model?: string; /** Reasoning effort override */ reasoningEffort?: string; + /** Context tier override (context window size) */ + contextTier?: string; /** Whether the spawned work produces commits (sub-session only) */ producesCommits?: boolean; /** Whether to run in background */ @@ -180,6 +182,7 @@ export class TaskSpawnBackend implements SpawnBackend { model: request.model, clientName: `squad-agent-${request.agentName}`, ...(request.reasoningEffort ? { reasoningEffort: request.reasoningEffort } : {}), + ...(request.contextTier ? { contextTier: request.contextTier } : {}), }), this.options.createSessionTimeoutMs ?? DEFAULT_CREATE_SESSION_TIMEOUT_MS, ); @@ -282,6 +285,7 @@ export class SessionSpawnBackend implements SpawnBackend { mode: this.options.mode, model: request.model, ...(request.reasoningEffort ? { reasoning_effort: request.reasoningEffort } : {}), + ...(request.contextTier ? { context_tier: request.contextTier } : {}), }, }), this.options.createSessionTimeoutMs ?? DEFAULT_CREATE_SESSION_TIMEOUT_MS, diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 4fe2b89cb..abbfcaaed 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -383,6 +383,33 @@ When the resolved reasoning effort is not `auto` or default, include it in the a Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.7-1m-internal ยท xhigh) โ€” deep architecture analysis`). +### Per-Agent Context Tier + +Context tier controls the size of the model's context window โ€” how much conversation, code, and instruction the model can hold at once. Larger tiers fit more context but cost more per token. This is SEPARATE from model selection and reasoning effort โ€” you can run the same model at different context tiers. + +Valid tiers: `default`, `long_context`. The value `auto` means "let the model decide" (platform default). A `long_context` request clamps to `default` on models that only support a single window. + +**Resolution โ€” check these layers in order (first match wins):** + +1. **Persistent Config:** `.squad/config.json` โ†’ `agentContextTierOverrides.{agentName}`, then `defaultContextTier` +2. **User directive:** User says "use long context" or "1M window" โ†’ apply to this spawn +3. **Charter preference:** Agent's `## Model` section โ†’ `**Context Tier:** long_context` +4. **Default:** Do not set a context tier (platform decides) + +**When user requests a larger window:** Use the SAME model with a different context tier โ€” do NOT switch to a different model variant. Context tier is a session parameter, not a model choice. + +- **When user says "always use long context" / "1M window by default":** Write `defaultContextTier` to `.squad/config.json`. Acknowledge: `โœ… Context tier saved: long_context โ€” all future sessions will use this until changed.` +- **When user says "use long context for {agent}":** Write to `agentContextTierOverrides.{agent}` in `.squad/config.json`. Acknowledge: `โœ… {Agent} will always use long context โ€” saved to config.` +- **When user says "clear context tier preference":** Remove context tier fields from `.squad/config.json`. Acknowledge: `โœ… Context tier preference cleared โ€” returning to automatic.` + +**Passing context tier to spawns:** + +When the resolved context tier is not `auto` or default, include it in the agent's charter-compiled spawn prompt or session config. The SDK threads it through to `SquadSessionConfig.contextTier` automatically, clamping to what the model supports. + +**Spawn output format โ€” show the model choice and tier:** + +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.8 ยท long context) โ€” 1M-token window for deep architecture analysis`). + ### Client Compatibility Detect the client surface once per session and adapt spawning behavior accordingly: CLI uses `task`/`read_agent`, VS Code uses `runSubagent`. diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 4fe2b89cb..abbfcaaed 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -383,6 +383,33 @@ When the resolved reasoning effort is not `auto` or default, include it in the a Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.7-1m-internal ยท xhigh) โ€” deep architecture analysis`). +### Per-Agent Context Tier + +Context tier controls the size of the model's context window โ€” how much conversation, code, and instruction the model can hold at once. Larger tiers fit more context but cost more per token. This is SEPARATE from model selection and reasoning effort โ€” you can run the same model at different context tiers. + +Valid tiers: `default`, `long_context`. The value `auto` means "let the model decide" (platform default). A `long_context` request clamps to `default` on models that only support a single window. + +**Resolution โ€” check these layers in order (first match wins):** + +1. **Persistent Config:** `.squad/config.json` โ†’ `agentContextTierOverrides.{agentName}`, then `defaultContextTier` +2. **User directive:** User says "use long context" or "1M window" โ†’ apply to this spawn +3. **Charter preference:** Agent's `## Model` section โ†’ `**Context Tier:** long_context` +4. **Default:** Do not set a context tier (platform decides) + +**When user requests a larger window:** Use the SAME model with a different context tier โ€” do NOT switch to a different model variant. Context tier is a session parameter, not a model choice. + +- **When user says "always use long context" / "1M window by default":** Write `defaultContextTier` to `.squad/config.json`. Acknowledge: `โœ… Context tier saved: long_context โ€” all future sessions will use this until changed.` +- **When user says "use long context for {agent}":** Write to `agentContextTierOverrides.{agent}` in `.squad/config.json`. Acknowledge: `โœ… {Agent} will always use long context โ€” saved to config.` +- **When user says "clear context tier preference":** Remove context tier fields from `.squad/config.json`. Acknowledge: `โœ… Context tier preference cleared โ€” returning to automatic.` + +**Passing context tier to spawns:** + +When the resolved context tier is not `auto` or default, include it in the agent's charter-compiled spawn prompt or session config. The SDK threads it through to `SquadSessionConfig.contextTier` automatically, clamping to what the model supports. + +**Spawn output format โ€” show the model choice and tier:** + +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `๐Ÿง  DeepThink (claude-opus-4.8 ยท long context) โ€” 1M-token window for deep architecture analysis`). + ### Client Compatibility Detect the client surface once per session and adapt spawning behavior accordingly: CLI uses `task`/`read_agent`, VS Code uses `runSubagent`. diff --git a/test/model-preference.test.ts b/test/model-preference.test.ts index 140dc2a51..60a69526f 100644 --- a/test/model-preference.test.ts +++ b/test/model-preference.test.ts @@ -24,6 +24,12 @@ import { writeAgentReasoningEffortOverrides, resolveReasoningEffort, clampReasoningEffort, + readContextTier, + readAgentContextTierOverrides, + writeContextTier, + writeAgentContextTierOverrides, + resolveContextTier, + clampContextTier, } from '@bradygaster/squad-sdk/config'; // Temp directory for each test @@ -653,3 +659,345 @@ describe('clampReasoningEffort', () => { expect(clampReasoningEffort('low', ['medium', 'high'])).toBe('medium'); }); }); + +// ============================================================================ +// readContextTier +// ============================================================================ + +describe('readContextTier', () => { + it('returns null when config.json does not exist', () => { + expect(readContextTier(squadDir)).toBeNull(); + }); + + it('returns null when config.json has no defaultContextTier', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1 }) + ); + expect(readContextTier(squadDir)).toBeNull(); + }); + + it('returns the tier when defaultContextTier is set', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultContextTier: 'long_context' }) + ); + expect(readContextTier(squadDir)).toBe('long_context'); + }); + + it('returns "auto" when persisted (sentinel is readable)', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultContextTier: 'auto' }) + ); + expect(readContextTier(squadDir)).toBe('auto'); + }); + + it('returns null on malformed JSON', () => { + writeFileSync(join(squadDir, 'config.json'), '{ broken json'); + expect(readContextTier(squadDir)).toBeNull(); + }); +}); + +// ============================================================================ +// readAgentContextTierOverrides +// ============================================================================ + +describe('readAgentContextTierOverrides', () => { + it('returns empty object when config.json does not exist', () => { + expect(readAgentContextTierOverrides(squadDir)).toEqual({}); + }); + + it('reads per-agent overrides', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ + version: 1, + agentContextTierOverrides: { + fenster: 'long_context', + mcmanus: 'default', + }, + }) + ); + const overrides = readAgentContextTierOverrides(squadDir); + expect(overrides.fenster).toBe('long_context'); + expect(overrides.mcmanus).toBe('default'); + }); + + it('drops "auto" and invalid values in overrides', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ + version: 1, + agentContextTierOverrides: { + fenster: 'long_context', + keaton: 'auto', + bad: 'invalid-tier', + }, + }) + ); + const overrides = readAgentContextTierOverrides(squadDir); + expect(overrides.fenster).toBe('long_context'); + expect(overrides).not.toHaveProperty('keaton'); + expect(overrides).not.toHaveProperty('bad'); + }); +}); + +// ============================================================================ +// writeContextTier +// ============================================================================ + +describe('writeContextTier', () => { + it('creates config.json if missing', () => { + writeContextTier(squadDir, 'long_context'); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw.version).toBe(1); + expect(raw.defaultContextTier).toBe('long_context'); + }); + + it('merges with existing config without clobbering', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultModel: 'claude-opus-4.6' }) + ); + writeContextTier(squadDir, 'long_context'); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw.defaultModel).toBe('claude-opus-4.6'); + expect(raw.defaultContextTier).toBe('long_context'); + }); + + it('removes defaultContextTier when set to null', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultContextTier: 'long_context' }) + ); + writeContextTier(squadDir, null); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw).not.toHaveProperty('defaultContextTier'); + }); + + it('does not write an invalid tier', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultModel: 'claude-opus-4.6' }) + ); + writeContextTier(squadDir, 'invalid-tier' as never); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw).not.toHaveProperty('defaultContextTier'); + expect(raw.defaultModel).toBe('claude-opus-4.6'); + }); +}); + +// ============================================================================ +// writeAgentContextTierOverrides +// ============================================================================ + +describe('writeAgentContextTierOverrides', () => { + it('creates config.json with overrides', () => { + writeAgentContextTierOverrides(squadDir, { fenster: 'long_context' }); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw.agentContextTierOverrides.fenster).toBe('long_context'); + }); + + it('removes the field when set to null', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ + version: 1, + agentContextTierOverrides: { fenster: 'long_context' }, + }) + ); + writeAgentContextTierOverrides(squadDir, null); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw).not.toHaveProperty('agentContextTierOverrides'); + }); + + it('removes the field when given an empty object', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ + version: 1, + agentContextTierOverrides: { fenster: 'long_context' }, + }) + ); + writeAgentContextTierOverrides(squadDir, {}); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw).not.toHaveProperty('agentContextTierOverrides'); + }); + + it('keeps valid entries and drops invalid ones', () => { + writeAgentContextTierOverrides(squadDir, { + fenster: 'long_context', + bad: 'invalid-tier' as never, + }); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf8')); + expect(raw.agentContextTierOverrides.fenster).toBe('long_context'); + expect(raw.agentContextTierOverrides).not.toHaveProperty('bad'); + }); +}); + +// ============================================================================ +// resolveContextTier +// ============================================================================ + +describe('resolveContextTier', () => { + it('returns undefined when nothing is set', () => { + expect(resolveContextTier({})).toBeUndefined(); + }); + + it('Layer 2: charter preference wins over default', () => { + expect( + resolveContextTier({ charterPreference: 'long_context' }) + ).toBe('long_context'); + }); + + it('Layer 1: spawn override wins over charter', () => { + expect( + resolveContextTier({ + spawnOverride: 'long_context', + charterPreference: 'default', + }) + ).toBe('long_context'); + }); + + it('Layer 0b: persistent config wins over spawn override', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultContextTier: 'default' }) + ); + expect( + resolveContextTier({ + squadDir, + spawnOverride: 'long_context', + charterPreference: 'long_context', + }) + ).toBe('default'); + }); + + it('Layer 0a: per-agent override wins over global', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ + version: 1, + defaultContextTier: 'default', + agentContextTierOverrides: { fenster: 'long_context' }, + }) + ); + expect( + resolveContextTier({ + agentName: 'fenster', + squadDir, + spawnOverride: 'default', + }) + ).toBe('long_context'); + }); + + it('auto is treated as absent at all layers', () => { + expect( + resolveContextTier({ charterPreference: 'auto' }) + ).toBeUndefined(); + + expect( + resolveContextTier({ spawnOverride: 'auto', charterPreference: 'long_context' }) + ).toBe('long_context'); + }); + + it('falls through to charter when config has no tier', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultModel: 'claude-opus-4.6' }) + ); + expect( + resolveContextTier({ + squadDir, + charterPreference: 'long_context', + }) + ).toBe('long_context'); + }); + + it('clamps to fallback when requested tier is unsupported', () => { + // Model only supports the default tier โ€” requesting long_context clamps to default + expect( + resolveContextTier({ + charterPreference: 'long_context', + supportedContextTiers: ['default'], + }) + ).toBe('default'); + }); + + it('passes through when within model capabilities', () => { + expect( + resolveContextTier({ + charterPreference: 'long_context', + supportedContextTiers: ['default', 'long_context'], + }) + ).toBe('long_context'); + }); + + it('ignores invalid spawnOverride values', () => { + expect( + resolveContextTier({ spawnOverride: 'invalid', charterPreference: 'long_context' }) + ).toBe('long_context'); + }); + + it('ignores invalid charterPreference values', () => { + expect( + resolveContextTier({ charterPreference: 'turbo' }) + ).toBeUndefined(); + }); + + it('ignores invalid persisted defaultContextTier', () => { + writeFileSync( + join(squadDir, 'config.json'), + JSON.stringify({ version: 1, defaultContextTier: 'invalid' }) + ); + expect( + resolveContextTier({ + squadDir, + charterPreference: 'long_context', + }) + ).toBe('long_context'); + }); + + it('ignores all-invalid layers and returns undefined', () => { + expect( + resolveContextTier({ + spawnOverride: 'invalid', + charterPreference: 'auto', + }) + ).toBeUndefined(); + }); +}); + +// ============================================================================ +// clampContextTier +// ============================================================================ + +describe('clampContextTier', () => { + it('returns undefined when no tier requested', () => { + expect(clampContextTier(undefined, ['default', 'long_context'])).toBeUndefined(); + }); + + it('trusts a valid request when model support is unknown', () => { + expect(clampContextTier('long_context', undefined)).toBe('long_context'); + expect(clampContextTier('long_context', [])).toBe('long_context'); + }); + + it('passes through when tier is directly supported', () => { + expect(clampContextTier('default', ['default', 'long_context'])).toBe('default'); + expect(clampContextTier('long_context', ['default', 'long_context'])).toBe('long_context'); + }); + + it('clamps to default when requested tier is unsupported', () => { + expect(clampContextTier('long_context', ['default'])).toBe('default'); + }); + + it('clamps to the model default when provided', () => { + // long_context unsupported, model default is explicitly "default" + expect(clampContextTier('long_context', ['default'], 'default')).toBe('default'); + }); + + it('returns fallback for an unrecognized tier value', () => { + expect(clampContextTier('turbo', ['default', 'long_context'])).toBe('default'); + }); +});