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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ See `config/agents.example.json` for the full schema. Key fields:
"model": "claude-sonnet-4-6",
"toolPolicy": "readonly",
"replyMode": "direct", // reply in-place
"maxBudgetUsd": null, // disable SDK budget limit; use a number to cap cost
"persona": "./personas/assistant.md",
"knowledge": ["team.md"]
},
Expand All @@ -156,6 +157,7 @@ All options are documented in `.env.example`. Key ones:
| `ANTHROPIC_API_KEY` | Yes | Anthropic API key |
| `FEISHU_EVENT_MODE` | No | `websocket` (default) or `webhook` |
| `DEFAULT_WORK_DIR` | No | Root directory for projects (default: parent of cwd) |
| `CLAUDE_MAX_BUDGET_USD` | No | Per-query SDK budget in USD; set `off`, `false`, `none`, `disabled`, or `0` to omit it |
| `ALLOWED_USER_IDS` | No | Comma-separated Feishu open_ids (empty = allow all) |
| `OWNER_USER_ID` | No | Admin user with full permissions |
| `MEMORY_ENABLED` | No | Enable long-term memory system |
Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ describe('AgentConfigFileSchema', () => {
expect(result.success).toBe(false);
});

it('accepts null maxBudgetUsd to disable budget', () => {
const result = AgentConfigFileSchema.safeParse({
defaults: { maxBudgetUsd: null },
agents: [{ id: 'test', maxBudgetUsd: null }],
});
expect(result.success).toBe(true);
});
it('rejects zero maxTurns', () => {
const result = AgentConfigFileSchema.safeParse({
agents: [{ id: 'test', maxTurns: 0 }],
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ describe('config', () => {
const { config } = await loadConfig();
expect(config.claude.toolTimeoutSeconds).toBe(1200);
});

it('should parse CLAUDE_MAX_BUDGET_USD as a number', async () => {
vi.stubEnv('CLAUDE_MAX_BUDGET_USD', '12.5');
const { config } = await loadConfig();
expect(config.claude.maxBudgetUsd).toBe(12.5);
});

it('should disable CLAUDE_MAX_BUDGET_USD when set to off', async () => {
vi.stubEnv('CLAUDE_MAX_BUDGET_USD', 'off');
const { config } = await loadConfig();
expect(config.claude.maxBudgetUsd).toBeUndefined();
});

it('should disable CLAUDE_MAX_BUDGET_USD when set to 0', async () => {
vi.stubEnv('CLAUDE_MAX_BUDGET_USD', '0');
const { config } = await loadConfig();
expect(config.claude.maxBudgetUsd).toBeUndefined();
});
});

describe('defaults', () => {
Expand Down
16 changes: 16 additions & 0 deletions src/agent/__tests__/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,4 +410,20 @@ describe('maxBudgetUsd / maxTurns fallback', () => {
const { agentRegistry } = await import('../registry.js');
expect(agentRegistry.get('pm')!.maxBudgetUsd).toBe(100);
});
it('should let file-level null maxBudgetUsd disable SDK budget', async () => {
await setupWithClaudeConfig(
{ defaults: { maxBudgetUsd: null }, agents: [{ id: 'pm' }] },
{ maxBudgetUsd: 50 },
);
const { agentRegistry } = await import('../registry.js');
expect(agentRegistry.get('pm')!.maxBudgetUsd).toBeUndefined();
});
it('should let agent-level null maxBudgetUsd override defaults and config', async () => {
await setupWithClaudeConfig(
{ defaults: { maxBudgetUsd: 20 }, agents: [{ id: 'pm', maxBudgetUsd: null }] },
{ maxBudgetUsd: 50 },
);
const { agentRegistry } = await import('../registry.js');
expect(agentRegistry.get('pm')!.maxBudgetUsd).toBeUndefined();
});
});
12 changes: 11 additions & 1 deletion src/agent/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ function resolveToolPolicy(policy: ToolPolicyValue | undefined): {
}

// ─── Merge 逻辑 ────────────────────────────────────────────
function resolveMaxBudgetUsd(input: AgentConfigInput, defaults: AgentDefaults): number | undefined {
if (input.maxBudgetUsd !== undefined) {
return input.maxBudgetUsd === null ? undefined : input.maxBudgetUsd;
}
if (defaults.maxBudgetUsd !== undefined) {
return defaults.maxBudgetUsd === null ? undefined : defaults.maxBudgetUsd;
}
return config.claude.maxBudgetUsd;
}


/**
* 合并单个 agent 配置:input → defaults → 内置默认值
Expand All @@ -92,7 +102,7 @@ function mergeAgentConfig(input: AgentConfigInput, defaults: AgentDefaults): Age
toolPolicy,
readOnly,
settingSources: (input.settingSources ?? defaults.settingSources ?? BUILTIN_DEFAULTS.settingSources!) as ('user' | 'project')[],
maxBudgetUsd: input.maxBudgetUsd ?? defaults.maxBudgetUsd ?? config.claude.maxBudgetUsd,
maxBudgetUsd: resolveMaxBudgetUsd(input, defaults),
maxTurns: input.maxTurns ?? defaults.maxTurns ?? config.claude.maxTurns,
requiresApproval: input.requiresApproval ?? defaults.requiresApproval ?? BUILTIN_DEFAULTS.requiresApproval!,
replyMode: input.replyMode ?? defaults.replyMode ?? BUILTIN_DEFAULTS.replyMode! as 'direct' | 'thread',
Expand Down
7 changes: 5 additions & 2 deletions src/agent/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const ToolPolicyDetailedSchema = z.object({

/** toolPolicy 支持两种格式 */
export const ToolPolicySchema = z.union([ToolPolicySimpleSchema, ToolPolicyDetailedSchema]);
/** 单次 query 最大花费 (USD);null 表示不向 SDK 传 maxBudgetUsd */
const MaxBudgetUsdSchema = z.number().positive().max(1000).nullable();


// ─── 飞书账号 ────────────────────────────────────────────────

Expand Down Expand Up @@ -66,7 +69,7 @@ export const AgentConfigInputSchema = z.object({
/** Settings 源 */
settingSources: z.array(z.enum(['user', 'project'])).optional(),
/** 单次 query 最大花费 (USD),上限 1000 */
maxBudgetUsd: z.number().positive().max(1000).optional(),
maxBudgetUsd: MaxBudgetUsdSchema.optional(),
/** 单次 query 最大轮次,上限 10000 */
maxTurns: z.number().int().positive().max(10000).optional(),
/** 是否需要写权限审批 */
Expand All @@ -91,7 +94,7 @@ export const AgentDefaultsSchema = z.object({
model: z.string().optional(),
toolPolicy: ToolPolicySchema.optional(),
settingSources: z.array(z.enum(['user', 'project'])).optional(),
maxBudgetUsd: z.number().positive().max(1000).optional(),
maxBudgetUsd: MaxBudgetUsdSchema.optional(),
maxTurns: z.number().int().positive().max(10000).optional(),
requiresApproval: z.boolean().optional(),
replyMode: z.enum(['direct', 'thread']).optional(),
Expand Down
4 changes: 2 additions & 2 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ export interface AgentConfig {
readOnly: boolean;
/** 加载哪些 settings 源 */
settingSources: ('user' | 'project')[];
/** 单次 query 最大花费 (USD) */
maxBudgetUsd: number;
/** 单次 query 最大花费 (USD)。省略表示不向 SDK 传 maxBudgetUsd */
maxBudgetUsd?: number;
/** 单次 query 最大轮次 */
maxTurns: number;
/** 是否需要写权限审批(non-owner 场景) */
Expand Down
19 changes: 19 additions & 0 deletions src/claude/__tests__/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,25 @@ describe('ClaudeExecutor', () => {
expect(opts.maxTurns).toBe(5);
expect(opts.maxBudgetUsd).toBe(0.5);
});

it('should omit maxBudgetUsd when config disables it', async () => {
const { config } = await import('../../config.js');
const prevBudget = config.claude.maxBudgetUsd;
config.claude.maxBudgetUsd = undefined;
try {
await executor.execute(makeInput());
const opts = mockQuery.mock.calls[0][0].options;
expect(opts).not.toHaveProperty('maxBudgetUsd');
} finally {
config.claude.maxBudgetUsd = prevBudget;
}
});

it('should omit maxBudgetUsd when caller explicitly disables it', async () => {
await executor.execute(makeInput({ maxBudgetUsd: undefined }));
const opts = mockQuery.mock.calls[0][0].options;
expect(opts).not.toHaveProperty('maxBudgetUsd');
});
});

describe('killSessionsForChat', () => {
Expand Down
5 changes: 4 additions & 1 deletion src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,9 @@ export class ClaudeExecutor {
readOnly, images, documents,
} = input;

const hasMaxBudgetUsdOverride = Object.prototype.hasOwnProperty.call(input, 'maxBudgetUsd');
const effectiveMaxBudgetUsd = hasMaxBudgetUsdOverride ? maxBudgetUsd : config.claude.maxBudgetUsd;

const startTime = Date.now();
const abortController = new AbortController();
const idleTimeoutMs = (input.timeoutSeconds ?? config.claude.timeoutSeconds) * 1000;
Expand Down Expand Up @@ -1041,7 +1044,7 @@ export class ClaudeExecutor {

// 预算和限制 — 默认值从 config 读取,调用方可覆盖
maxTurns: maxTurns ?? config.claude.maxTurns,
maxBudgetUsd: maxBudgetUsd ?? config.claude.maxBudgetUsd,
...(effectiveMaxBudgetUsd !== undefined ? { maxBudgetUsd: effectiveMaxBudgetUsd } : {}),

// 会话续接
...(effectiveResumeId ? { resume: effectiveResumeId } : {}),
Expand Down
2 changes: 1 addition & 1 deletion src/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface CompactResult {
export interface ExecuteOptions {
/** 覆盖默认 maxTurns (默认 50) */
maxTurns?: number;
/** 覆盖默认 maxBudgetUsd (默认 5) */
/** 覆盖默认 maxBudgetUsd;显式传 undefined 表示不向 SDK 传预算限制 */
maxBudgetUsd?: number;
/** 不注入 setup_workspace MCP tool (restart 时使用) */
disableWorkspaceTool?: boolean;
Expand Down
24 changes: 22 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@ export function parsePositiveInt(raw: string | undefined, fallback: number): num
return Number.isFinite(n) && n > 0 ? n : fallback;
}

/**
* CLAUDE_MAX_BUDGET_USD 解析:
* - 未设置/空字符串:使用 fallback
* - off/false/none/disabled/0:关闭 SDK maxBudgetUsd,不向 SDK 传该字段
* - 正数:启用预算熔断
*/
export function parseOptionalMaxBudgetUsd(raw: string | undefined, fallback: number): number | undefined {
const value = raw?.trim();
if (!value) return fallback;

const normalized = value.toLowerCase();
if (['off', 'false', 'none', 'disabled', 'disable'].includes(normalized)) {
return undefined;
}

const n = Number(value);
if (n === 0) return undefined;
return Number.isFinite(n) && n > 0 ? n : fallback;
}

function parseGroupConfigs(raw?: string): Record<string, GroupConfig> {
if (!raw?.trim()) return {};
try {
Expand Down Expand Up @@ -91,8 +111,8 @@ export const config = {
effort: (process.env.CLAUDE_EFFORT || 'xhigh') as 'low' | 'medium' | 'high' | 'xhigh' | 'max',
/** 单次 query 最大轮次 (Agent ↔ Tool 来回次数),兜底防死循环 */
maxTurns: parseInt(process.env.CLAUDE_MAX_TURNS || '500', 10),
/** 单次 query 最大花费 (美元),真正的费用熔断 */
maxBudgetUsd: parseFloat(process.env.CLAUDE_MAX_BUDGET_USD || '50'),
/** 单次 query 最大花费 (美元)。设置 CLAUDE_MAX_BUDGET_USD=off/false/none/disabled/0 可关闭 */
maxBudgetUsd: parseOptionalMaxBudgetUsd(process.env.CLAUDE_MAX_BUDGET_USD, 50),
},

// 工作区配置
Expand Down
6 changes: 3 additions & 3 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1444,7 +1444,7 @@ async function handleSlashCommand(
`**工具策略**: ${agentCfg?.toolPolicy ?? '(默认)'}`,
`**回复模式**: ${agentCfg?.replyMode ?? '(默认)'}`,
`**需要审批**: ${agentCfg?.requiresApproval ? '是' : '否'}`,
`**预算**: $${agentCfg?.maxBudgetUsd ?? '?'} / ${agentCfg?.maxTurns ?? '?'} turns`,
`**预算**: ${agentCfg ? (agentCfg.maxBudgetUsd === undefined ? '关闭' : '$' + agentCfg.maxBudgetUsd) : '?'} / ${agentCfg?.maxTurns ?? '?'} turns`,
];

if (agentCfg?.editablePathPatterns?.length) {
Expand Down Expand Up @@ -2799,7 +2799,7 @@ export async function executeClaudeTask(
readOnly,
model: agentCfg?.model,
maxTurns: agentCfg?.maxTurns,
maxBudgetUsd: agentCfg?.maxBudgetUsd,
...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}),
settingSources: agentCfg?.settingSources,
toolAllow: agentCfg?.toolAllow,
toolDeny: agentCfg?.toolDeny,
Expand Down Expand Up @@ -2950,7 +2950,7 @@ export async function executeClaudeTask(
readOnly,
model: agentCfg?.model,
maxTurns: agentCfg?.maxTurns,
maxBudgetUsd: agentCfg?.maxBudgetUsd,
...(agentCfg ? { maxBudgetUsd: agentCfg.maxBudgetUsd } : {}),
settingSources: agentCfg?.settingSources,
toolAllow: agentCfg?.toolAllow,
toolDeny: agentCfg?.toolDeny,
Expand Down
Loading