feat(project): per-project new-chat defaults (agent, workspace, approval, thinking, model) (#191)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 22:52:34 +08:00
committed by GitHub
parent 7cc5c30e71
commit 89435bf62a
27 changed files with 1980 additions and 638 deletions
+32 -11
View File
@@ -94,7 +94,9 @@ export interface CreateSessionOptions {
/**
* 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`;
* - `undefined` (omitted) falls back to the config chain: the Agent's explicit
* `model.thinking_level` > the Project's `default_chat.thinking_level` > the built-in
* `"medium"` (see `configuredThinkingLevel`);
* - `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
@@ -156,6 +158,23 @@ export class Agent {
readonly projectConfig: ProjectConfig,
) {}
/**
* A Session's default thinking level when no explicit per-session level is given — the
* resolution chain (the single rule, keep both sites on it): the Agent's explicit
* `model.thinking_level` > the Project's `default_chat.thinking_level` > the built-in
* `"medium"` (the documented Agent default). Mirrored by the web draft picker's DISPLAY
* (web features/chat/thinking-level.ts `effectiveThinkingLevel`): the picker shows this
* effective value, and a pick writes through to the AGENT config — the project default
* is only ever a fallback, never overwritten from there.
*/
private configuredThinkingLevel(): ThinkingLevelName {
return (
this.state.systemConfig.model?.thinking_level ??
this.projectConfig.default_chat?.thinking_level ??
"medium"
);
}
/**
* Create a Session in the specified (or a temporary) Workspace.
* Docs: /docs/sessions-and-traces § "Run model".
@@ -222,13 +241,14 @@ export class Agent {
}
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.
// Effective thinking level (tri-state option): a value wins over every config; `null` —
// how subagent spawning says "the parent has none" — suppresses the config fallback
// entirely; only `undefined` (no option) reads the config chain (Agent explicit >
// Project default_chat > built-in "medium", see configuredThinkingLevel).
const thinkingLevel =
opts.thinkingLevel === null
? undefined
: (opts.thinkingLevel ?? this.state.systemConfig.model?.thinking_level);
: (opts.thinkingLevel ?? this.configuredThinkingLevel());
// 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);
@@ -383,12 +403,13 @@ export class Agent {
// 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;
// The default thinking level comes from the current config chain only (Agent explicit >
// Project default_chat > built-in "medium", the same configuredThinkingLevel chain
// createSession uses): 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.configuredThinkingLevel();
const rt = await this.buildRuntime({
sessionId,
+92 -6
View File
@@ -27,6 +27,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import type { ThinkingLevelName } from "../interfaces.js";
import { presetModelEntries } from "./model-catalog.js";
import { projectConfigPath } from "./paths.js";
@@ -106,6 +107,41 @@ export interface ModelEntry {
created_at?: string;
}
/** Approval modes storable in `[default_chat]` (mirrors the Web/CLI ApprovalMode enum). */
export const CHAT_APPROVAL_MODES = ["allow-all", "deny-all", "read-only", "always-ask"] as const;
export type ChatApprovalMode = (typeof CHAT_APPROVAL_MODES)[number];
/**
* Thinking levels storable in `[default_chat]`: the four selectable tiers only — never
* `"none"` (the project default is a fallback for Agents without an explicit level, and
* "no thinking" is not offered as a default; see the web picker's SELECTABLE_THINKING_LEVELS).
*/
export type DefaultChatThinkingLevel = Exclude<ThinkingLevelName, "none">;
export const DEFAULT_CHAT_THINKING_LEVELS: readonly DefaultChatThinkingLevel[] = [
"low",
"medium",
"high",
"xhigh",
];
/**
* New-chat defaults (`[default_chat]`): per-Project prefill for newly created chats.
* Every key is optional and independent:
* - `agent_id`: the Agent preselected on the draft page (must name an existing Agent);
* - `workspace`: the prefilled Workspace directory (absent/empty = auto temp directory);
* - `approval_mode`: the prefilled approval mode (absent = the built-in "allow-all");
* - `thinking_level`: fallback thinking level for Agents whose config has no explicit
* `model.thinking_level` (see Agent's thinking-level resolution chain in agent.ts).
* The default Model is deliberately NOT here: it stays the top-level `default_model`
* (single-sourced with the models page — never a second key).
*/
export interface ProjectChatDefaults {
agent_id?: string;
workspace?: string;
approval_mode?: ChatApprovalMode;
thinking_level?: DefaultChatThinkingLevel;
}
/**
* Project-level config.
* Docs: /docs/configuration § "Project config".
@@ -122,6 +158,11 @@ export interface ProjectConfig {
* default — models that don't support images won't be able to read images.
*/
vision_model?: ModelRef;
/**
* New-chat defaults block; absent by default (`defaultProjectConfig` never includes it —
* absent = the pre-existing behavior). Loaded tolerantly: invalid values drop per key.
*/
default_chat?: ProjectChatDefaults;
models: ModelEntry[];
}
@@ -175,11 +216,44 @@ function assertModelEntry(file: string, entry: unknown): ModelEntry {
return entry as ModelEntry;
}
/**
* Leniently parses the `[default_chat]` block (new-chat defaults): each key is validated
* independently and an invalid value (wrong type / unknown enum member / `"none"` as a
* thinking level) drops that key rather than failing the load — the block only ever
* prefills new chats, so bad data must never block reading the model table. Returns
* undefined when the value is not a table or nothing valid remains (absent block =
* the pre-existing behavior).
*/
function parseDefaultChat(value: unknown): ProjectChatDefaults | undefined {
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
const t = value as Record<string, unknown>;
const out: ProjectChatDefaults = {};
if (typeof t.agent_id === "string" && t.agent_id !== "") out.agent_id = t.agent_id;
if (typeof t.workspace === "string" && t.workspace !== "") out.workspace = t.workspace;
if (
typeof t.approval_mode === "string" &&
(CHAT_APPROVAL_MODES as readonly string[]).includes(t.approval_mode)
) {
out.approval_mode = t.approval_mode as ChatApprovalMode;
}
if (
typeof t.thinking_level === "string" &&
(DEFAULT_CHAT_THINKING_LEVELS as readonly string[]).includes(t.thinking_level)
) {
out.thinking_level = t.thinking_level as DefaultChatThinkingLevel;
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Loads the Project config; returns the default config (without writing to disk) if
* `.project_config.toml` doesn't exist. Returns plaintext (masking is applied at the interface
* layer); reports a clear error when the old format (a string reference / an entry missing
* provider) is read.
*
* The return literal below rebuilds the config from **known keys only** — any new top-level
* key must be added to `ProjectConfig` AND echoed here, or a load→save round trip (the CLI
* path) silently drops it.
*/
export async function loadProjectConfig(root: string, projectId: string): Promise<ProjectConfig> {
const file = projectConfigPath(root, projectId);
@@ -194,10 +268,12 @@ export async function loadProjectConfig(root: string, projectId: string): Promis
const parsed = (parseToml(raw) ?? {}) as Record<string, unknown>;
const defaultModel = parseRefField(file, "default_model", parsed.default_model);
const visionModel = parseRefField(file, "vision_model", parsed.vision_model);
const defaultChat = parseDefaultChat(parsed.default_chat);
return {
...(parsed.name !== undefined ? { name: parsed.name as string } : {}),
...(defaultModel !== undefined ? { default_model: defaultModel } : {}),
...(visionModel !== undefined ? { vision_model: visionModel } : {}),
...(defaultChat !== undefined ? { default_chat: defaultChat } : {}),
models: ((parsed.models as unknown[] | undefined) ?? []).map((m) => assertModelEntry(file, m)),
};
}
@@ -224,19 +300,29 @@ function isModelRefShape(v: unknown): v is ModelRef {
* `{ provider = "...", model_id = "..." }`; `models` is always
* placed last, since any table header after `[[models]]` would be read as its sub-table. Unknown
* extension fields are kept as-is.
*
* Same header rule for every other table-valued key (e.g. `[default_chat]`): once a table
* header is emitted, any `key = value` line below it would be parsed back as a member of
* that table — so entries whose serialization opens with a header are collected separately
* and emitted after all top-level `key = value` lines (and still before `[[models]]`),
* regardless of the object's key insertion order.
*/
export function renderProjectConfigToml(data: Record<string, unknown>): string {
const head: string[] = [];
const tables: string[] = [];
for (const [key, value] of Object.entries(data)) {
if (value === undefined || key === "models") continue;
head.push(
isModelRefShape(value)
? `${key} = ${tomlInlineRef(value)}`
: stringifyToml({ [key]: value }).trim(),
);
if (isModelRefShape(value)) {
head.push(`${key} = ${tomlInlineRef(value)}`);
continue;
}
const rendered = stringifyToml({ [key]: value }).trim();
// A rendering that opens with "[" is a table header ([key] / [[key]]) — defer it below
// every top-level key = value line; plain lines (scalars, inline arrays) stay in place.
(rendered.startsWith("[") ? tables : head).push(rendered);
}
const models = Array.isArray(data.models) ? data.models : [];
return [...head, stringifyToml({ models })].join("\n");
return [...head, ...tables, stringifyToml({ models })].join("\n");
}
/**