Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .squad/agents/fenster/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,4 +765,12 @@ Fixed 4 bugs in a single branch:
- The .gitignore append pattern (check exists, check includes, append with header comment) is reusable across init, link, and init-remote.
- FRAMEWORK_WORKFLOWS already filters init to 4 safe workflows; the opt-in flag is about user control, not safety.
- `process.env.NODE_NO_WARNINGS = '1'` set at runtime does NOT suppress Node.js ExperimentalWarning -- the env var is only checked at process start. Use a `process.emit` override to filter warnings at runtime.
- CI failures in --version tests were caused by `node:sqlite` ExperimentalWarning leaking into terminal output (3 lines instead of 1). Fixed with process.emit hook in cli-entry.ts.
- CI failures in --version tests were caused by `node:sqlite` ExperimentalWarning leaking into terminal output (3 lines instead of 1). Fixed with process.emit hook in cli-entry.ts.
### Model Config Pipeline - Issue #223 (2026-03-08)

- The charter generation pipeline had a format mismatch: build.ts emitted **Model:** value (flat) but charter-compiler.ts parsed ## Model + **Preferred:** value (structured). Model preferences were silently lost in the round-trip.
- Fix: AgentDefinition.model now accepts string or ModelPreference (backwards compatible). Build output uses proper ## Model section with Preferred, Rationale, Fallback lines.
- Added DefaultsDefinition and defineDefaults() for squad-level model defaults. Agents without explicit model inherit from config.defaults.model.
- The assertModelPreference() validator pattern (accept string or object, normalize internally) is reusable for any field that needs a simple-or-rich config shape.
- Charter-compiler now extracts modelRationale and modelFallback in addition to modelPreference.
- PR #245, branch squad/223-model-config.
Expand Down
23 changes: 23 additions & 0 deletions .squad/decisions/inbox/fenster-model-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Decision: Structured Model Preference in SDK Config

**Date:** 2026-03-08
**Author:** Fenster (Core Dev)
**Issue:** #223
**PR:** #245

## Context

Model preferences set via `defineAgent({ model: '...' })` were not reliably applied because the build pipeline emitted a flat `**Model:** value` line, but the charter-compiler expected a `## Model` section with `**Preferred:** value` format.

## Decision

1. **`AgentDefinition.model` accepts `string | ModelPreference`** — backwards compatible. A plain string is normalized to `{ preferred: string }` internally.
2. **`ModelPreference` interface** has three fields: `preferred` (required), `rationale` (optional), `fallback` (optional).
3. **Squad-level defaults** via `config.defaults.model` — applied to any agent that doesn't specify its own model preference.
4. **Charter output** uses the `## Model` section format with `**Preferred:**`, `**Rationale:**`, `**Fallback:**` lines — matching what the charter-compiler already parses.

## Impact

- All agents: charter generation now reliably round-trips model preferences.
- Verbal/Keaton: the 4-layer model selection hierarchy documented in squad.agent.md is now supported at the SDK config level.
- Anyone adding new config fields: use the `assertModelPreference()` pattern (accept string-or-object, normalize internally) for fields that need simple and rich config shapes.
24 changes: 24 additions & 0 deletions packages/squad-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@
"./commands/init-remote": {
"types": "./dist/cli/commands/init-remote.d.ts",
"import": "./dist/cli/commands/init-remote.js"
},
"./commands/watch": {
"types": "./dist/cli/commands/watch.d.ts",
"import": "./dist/cli/commands/watch.js"
},
"./commands/start": {
"types": "./dist/cli/commands/start.d.ts",
"import": "./dist/cli/commands/start.js"
},
"./commands/rc-tunnel": {
"types": "./dist/cli/commands/rc-tunnel.d.ts",
"import": "./dist/cli/commands/rc-tunnel.js"
},
"./commands/extract": {
"types": "./dist/cli/commands/extract.d.ts",
"import": "./dist/cli/commands/extract.js"
},
"./commands/copilot": {
"types": "./dist/cli/commands/copilot.d.ts",
"import": "./dist/cli/commands/copilot.js"
},
"./commands/copilot-bridge": {
"types": "./dist/cli/commands/copilot-bridge.d.ts",
"import": "./dist/cli/commands/copilot-bridge.js"
}
},
"files": [
Expand Down
52 changes: 51 additions & 1 deletion packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ try {
import fs from 'node:fs';
import path from 'node:path';
import { fatal, SquadError } from './cli/core/errors.js';
import { BOLD, RESET, DIM, RED } from './cli/core/output.js';
import { BOLD, RESET, DIM, RED, GREEN, YELLOW } from './cli/core/output.js';
import { runInit } from './cli/core/init.js';
import { resolveSquad, resolveGlobalSquadPath } from '@bradygaster/squad-sdk';
import { runShell } from './cli/shell/index.js';
Expand Down Expand Up @@ -145,6 +145,12 @@ async function main(): Promise<void> {
console.log(` --watch (rebuild on change)`);
console.log(` ${BOLD}aspire${RESET} Launch .NET Aspire dashboard for observability`);
console.log(` Flags: --docker (force Docker), --port <n> (dashboard port)`);
console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`);
console.log(` Usage: rc [--tunnel] [--port <n>] [--path <dir>]`);
console.log(` ${BOLD}copilot-bridge${RESET} Check Copilot ACP stdio compatibility`);
console.log(` ${BOLD}init-remote${RESET} Link project to remote team root (shorthand)`);
console.log(` Usage: init-remote <team-repo-path>`);
console.log(` ${BOLD}rc-tunnel${RESET} Check devtunnel CLI availability`);

console.log(` ${BOLD}help${RESET} Show this help message`);
console.log(`\nFlags:`);
Expand Down Expand Up @@ -410,6 +416,50 @@ async function main(): Promise<void> {
return;
}

if (cmd === 'rc' || cmd === 'remote-control') {
const { runRC } = await import('./cli/commands/rc.js');
const hasTunnel = args.includes('--tunnel');
const portIdx = args.indexOf('--port');
const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : 0;
const pathIdx = args.indexOf('--path');
const rcPath = (pathIdx !== -1 && args[pathIdx + 1]) ? args[pathIdx + 1] : undefined;
await runRC(rcPath || process.cwd(), { tunnel: hasTunnel, port });
return;
}

if (cmd === 'copilot-bridge') {
const { CopilotBridge } = await import('./cli/commands/copilot-bridge.js');
const result = await CopilotBridge.checkCompatibility();
if (result.compatible) {
console.log(`${GREEN}✓${RESET} ${result.message}`);
} else {
console.log(`${YELLOW}⚠${RESET} ${result.message}`);
}
return;
}

if (cmd === 'init-remote') {
const { writeRemoteConfig } = await import('./cli/commands/init-remote.js');
const teamPath = args[1];
if (!teamPath) {
fatal('Usage: squad init-remote <team-repo-path>');
}
const dest = process.cwd();
writeRemoteConfig(dest, teamPath);
await runInit(dest);
return;
}

if (cmd === 'rc-tunnel') {
const { isDevtunnelAvailable } = await import('./cli/commands/rc-tunnel.js');
if (isDevtunnelAvailable()) {
console.log(`${GREEN}✓${RESET} devtunnel CLI is available`);
} else {
console.log(`${YELLOW}⚠${RESET} devtunnel CLI not found. Install with: winget install Microsoft.devtunnel`);
}
return;
}

// Unknown command
fatal(`Unknown command: ${cmd}\n Run 'squad doctor' to check your setup, or 'squad help' for usage information.`);
}
Expand Down
47 changes: 41 additions & 6 deletions packages/squad-cli/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import type {
SquadSDKConfig,
AgentDefinition,
CeremonyDefinition,
ModelPreference,
BuilderModelId,
} from '@bradygaster/squad-sdk';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -163,7 +165,42 @@ function generateRoutingMd(config: SquadSDKConfig): string {
return lines.join('\n');
}

function generateCharterMd(agent: AgentDefinition): string {
/**
* Normalize a model field (string or ModelPreference) into a ModelPreference object.
*/
function normalizeModelPreference(
model: BuilderModelId | ModelPreference | undefined,
): ModelPreference | undefined {
if (!model) return undefined;
if (typeof model === 'string') return { preferred: model };
return model;
}

/**
* Render the `## Model` section for a charter, using the agent's model
* preference or falling back to squad-level defaults.
*/
function renderModelSection(
agentModel: BuilderModelId | ModelPreference | undefined,
defaultModel: BuilderModelId | ModelPreference | undefined,
): string[] {
const pref = normalizeModelPreference(agentModel) ?? normalizeModelPreference(defaultModel);
if (!pref) return [];

const lines: string[] = [];
lines.push('## Model\n');
lines.push(`**Preferred:** ${pref.preferred}`);
if (pref.rationale) {
lines.push(`**Rationale:** ${pref.rationale}`);
}
if (pref.fallback) {
lines.push(`**Fallback:** ${pref.fallback}`);
}
lines.push('');
return lines;
}

function generateCharterMd(agent: AgentDefinition, defaultModel?: BuilderModelId | ModelPreference): string {
const lines: string[] = [GENERATED_HEADER];
const name = agent.name;
const role = agent.role;
Expand All @@ -179,10 +216,7 @@ function generateCharterMd(agent: AgentDefinition): string {
lines.push('');
}

if (agent.model) {
lines.push(`**Model:** ${agent.model}`);
lines.push('');
}
lines.push(...renderModelSection(agent.model, defaultModel));

if (agent.tools && agent.tools.length > 0) {
lines.push('## Tools\n');
Expand Down Expand Up @@ -293,10 +327,11 @@ function buildFilePlan(config: SquadSDKConfig): GeneratedFile[] {
}

// Agent charters
const defaultModel = config.defaults?.model;
for (const agent of config.agents) {
files.push({
relPath: `.squad/agents/${agent.name}/charter.md`,
content: generateCharterMd(agent),
content: generateCharterMd(agent, defaultModel),
});
}

Expand Down
12 changes: 12 additions & 0 deletions packages/squad-sdk/src/agents/charter-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export interface ParsedCharter {
boundaries?: string;
/** Model preference from ## Model section */
modelPreference?: string;
/** Rationale for model preference from ## Model section */
modelRationale?: string;
/** Fallback model from ## Model section */
modelFallback?: string;
/** Collaboration section content */
collaboration?: string;
/** Full charter content */
Expand Down Expand Up @@ -238,6 +242,14 @@ export function parseCharterMarkdown(content: string): ParsedCharter {
if (preferredMatch) {
result.modelPreference = preferredMatch[1]!.trim();
}
const rationaleMatch = modelContent.match(/\*\*Rationale:\*\*\s*(.+)/i);
if (rationaleMatch) {
result.modelRationale = rationaleMatch[1]!.trim();
}
const fallbackMatch = modelContent.match(/\*\*Fallback:\*\*\s*(.+)/i);
if (fallbackMatch) {
result.modelFallback = fallbackMatch[1]!.trim();
}
}

// Extract ## Collaboration section
Expand Down
41 changes: 40 additions & 1 deletion packages/squad-sdk/src/builders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import type {
TeamDefinition,
AgentDefinition,
ModelPreference,
DefaultsDefinition,
RoutingDefinition,
CeremonyDefinition,
HooksDefinition,
Expand All @@ -25,6 +27,8 @@ export type {
AgentRef,
ScheduleExpression,
BuilderModelId,
ModelPreference,
DefaultsDefinition,
TeamDefinition,
AgentCapability,
AgentDefinition,
Expand Down Expand Up @@ -101,6 +105,20 @@ function assertStringUnion<T extends string>(
}
}

/** Validates a model field that accepts string or ModelPreference object. */
function assertModelPreference(value: unknown, field: string, builder: string): void {
if (value === undefined) return;
if (typeof value === 'string') return;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
assertNonEmptyString(obj.preferred, `${field}.preferred`, builder);
assertOptionalString(obj.rationale, `${field}.rationale`, builder);
assertOptionalString(obj.fallback, `${field}.fallback`, builder);
return;
}
throw new BuilderValidationError(builder, `"${field}" must be a model string or { preferred, rationale?, fallback? }`);
}

// ---------------------------------------------------------------------------
// defineTeam
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -157,7 +175,7 @@ export function defineAgent(config: AgentDefinition): AgentDefinition {
assertNonEmptyString(config.role, 'role', 'defineAgent');
assertOptionalString(config.description, 'description', 'defineAgent');
assertOptionalString(config.charter, 'charter', 'defineAgent');
assertOptionalString(config.model, 'model', 'defineAgent');
assertModelPreference(config.model, 'model', 'defineAgent');
assertOptionalArray(config.tools, 'tools', 'defineAgent');
assertOptionalArray(config.capabilities, 'capabilities', 'defineAgent');

Expand Down Expand Up @@ -352,6 +370,25 @@ export function defineTelemetry(config: TelemetryDefinition): TelemetryDefinitio
return config;
}

// ---------------------------------------------------------------------------
// defineDefaults
// ---------------------------------------------------------------------------

/**
* Define squad-level defaults applied to all agents unless overridden.
*
* ```ts
* const defaults = defineDefaults({
* model: { preferred: 'claude-sonnet-4', rationale: 'Good balance of speed and quality', fallback: 'claude-haiku-4.5' },
* });
* ```
*/
export function defineDefaults(config: DefaultsDefinition): DefaultsDefinition {
assertObject(config, 'defineDefaults');
assertModelPreference(config.model, 'model', 'defineDefaults');
return config;
}

// ---------------------------------------------------------------------------
// defineSquad — top-level composition
// ---------------------------------------------------------------------------
Expand All @@ -365,6 +402,7 @@ export function defineTelemetry(config: TelemetryDefinition): TelemetryDefinitio
* team: defineTeam({ name: 'Core', members: ['@edie'] }),
* agents: [defineAgent({ name: 'edie', role: 'TypeScript Engineer' })],
* routing: defineRouting({ rules: [...] }),
* defaults: defineDefaults({ model: 'claude-sonnet-4' }),
* });
* ```
*/
Expand All @@ -379,6 +417,7 @@ export function defineSquad(config: SquadSDKConfig): SquadSDKConfig {
defineAgent(agent);
}

if (config.defaults !== undefined) defineDefaults(config.defaults);
if (config.routing !== undefined) defineRouting(config.routing);
if (config.ceremonies !== undefined) {
assertArray(config.ceremonies, 'ceremonies', 'defineSquad');
Expand Down
40 changes: 38 additions & 2 deletions packages/squad-sdk/src/builders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ export type ScheduleExpression = string;
/** Model identifier (same domain as runtime ModelId). */
export type BuilderModelId = string;

// ---------------------------------------------------------------------------
// ModelPreference — structured model selection
// ---------------------------------------------------------------------------

/**
* Structured model preference for an agent or squad-level default.
* Supports the 4-layer model selection hierarchy:
* 1. User override (runtime)
* 2. Charter / config preference (this type)
* 3. Task-aware auto-selection (runtime)
* 4. Default (haiku)
*/
export interface ModelPreference {
/** Preferred model identifier (e.g. `"claude-sonnet-4.5"`). */
readonly preferred: BuilderModelId;

/** Why this model was chosen — helps coordinators respect the preference. */
readonly rationale?: string;

/** Fallback model if the preferred model is unavailable. */
readonly fallback?: BuilderModelId;
}

// ---------------------------------------------------------------------------
// DefaultsDefinition — squad-level defaults
// ---------------------------------------------------------------------------

/** Squad-level defaults applied to all agents unless overridden. */
export interface DefaultsDefinition {
/** Default model preference for agents that don't specify one. */
readonly model?: BuilderModelId | ModelPreference;
}

// ---------------------------------------------------------------------------
// TeamDefinition
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -66,8 +99,8 @@ export interface AgentDefinition {
/** Path to charter markdown or inline charter text. */
readonly charter?: string;

/** Preferred model identifier. */
readonly model?: BuilderModelId;
/** Preferred model identifier or structured model preference. */
readonly model?: BuilderModelId | ModelPreference;

/** Tools this agent is allowed to use. */
readonly tools?: readonly string[];
Expand Down Expand Up @@ -206,6 +239,9 @@ export interface SquadSDKConfig {
/** Agent definitions. */
readonly agents: readonly AgentDefinition[];

/** Squad-level defaults applied to agents unless overridden. */
readonly defaults?: DefaultsDefinition;

/** Routing rules. */
readonly routing?: RoutingDefinition;

Expand Down
Loading